Errors and retries
What the SDK raises, what each status code means, and what it retries for you.
HTTP endpoints on this page
- GET/health
Parameters and response shapes for each are in the API reference.
Every non-2xx response becomes a PulseAPIError. It carries the HTTP status and the server's own detail string, which is usually specific enough to act on without looking anything up.
PulseAPIError
from simudyne import PulseAPIError
try:
client.simulation.get_sim_params("not-a-real-sim-id")
except PulseAPIError as exc:
print(f"status: {exc.status_code}")
print(f"detail: {exc.detail}")status: 404
detail: Simulation 'not-a-real-sim-id' not found| Attribute | Type | Description |
|---|---|---|
| status_code | int | The HTTP status |
| detail | str | The server's detail field, or the raw body if it was not JSON |
| errors | list or None | The API's top-level errors list, when the response has one. The 422 for a bad provider or exchange puts its field-level errors here. None otherwise |
For a 422, the message is written to be read: it names each field that failed and why, rather than printing the raw list of validation errors.
It is importable from the package root, so from simudyne import PulseAPIError is all you need. The string form reads API Error (404): Simulation '…' not found, which is what an uncaught traceback shows.
Two SDK methods raise plain Python exceptions rather than PulseAPIError, because they fail before any request is made: validation.run() raises ValueError when it is given neither sim_ids nor sim_files, an empty list, or more than 25 simulations in total, and PulseABM() raises ValueError when no API key is set.
Status codes
| Status | Meaning | What to do |
|---|---|---|
| 401 | The key is missing, unknown, inactive or expired — or the account is inactive | Check the key. client.api_keys.list() shows which of yours are active |
| 403 | Your tier does not include this endpoint. The detail names the tier and links here | Not retryable. See Plans for what each tier includes |
| 404 | Not found, or not yours — a job or simulation belonging to another account reads as absent rather than forbidden. Also an unknown foundation model name passed as model_id | Check the id. For a missing output file the detail says whether the run failed, succeeded without producing it, or is still queued. fm.models() lists the model names |
| 400 | A malformed request the schema cannot express — an unknown filename, an empty sim_ids list, an unknown model_args key, or a model_id that matches more than one foundation model | The detail names what was rejected and often lists the valid options. For an ambiguous model, pass the exact model_id |
| 422 | The request was understood but cannot run. This covers a missing required field, a bad provider or exchange, an unknown or out-of-range scenario_params key, a field sent to the wrong engine (scenario on an FM run, model_id on an ABM run), an FM n_runs above 8, an unknown validation config field, an ABM run on a day that is not calibrated, or an FM run on data that does not exist or that the model does not accept | Not retryable. Fix the field the detail names. For an uncalibrated day or missing data, the detail lists the dates that are available |
| 429 | Free-tier download quota exhausted for the rolling window. Nothing was charged | Retried automatically (see below). Check profile.downloads() for when it rolls over |
| 502 / 503 | An upstream service — the simulation engine, the FM orchestrator — is unreachable | Retried automatically. Transient |
| 504 | The upstream took too long | Retried automatically |
A 422 on provider or exchange
These two fields became required, and the HKEX provider was renamed from hkex to omd. Rather than a bare validation error, the API answers with the valid options:
A recent change made 'provider' and 'exchange' required, and renamed the
HKEX provider to 'omd' (use 'omd', not 'hkex'). Valid providers: omd, bmll.
Valid exchanges: hkex_securities, hkex_derivatives, hkex_derivatives_mca, lse.Automatic retries
The client retries transient failures itself, with exponential backoff, before raising. You do not need to wrap calls in a retry loop for these.
| Setting | Default | Description |
|---|---|---|
| max_retries | 3 | Retry attempts after the first try, so at most 4 requests |
| timeout | 30 | Per-request timeout in seconds |
| Retried statuses | 429, 502, 503, 504 | Everything else raises immediately |
| Backoff | 1s, 2s, 4s | 2 ** attempt seconds between tries |
Connection errors and timeouts are retried on the same schedule and re-raised as the underlying requests exception if every attempt fails.
from simudyne import PulseABM
# More patience for a slow link
patient = PulseABM(timeout=120, max_retries=5)
# No retries at all, for a script that should fail fast
strict = PulseABM(max_retries=0)Whatever a client was built with, it will tell you:
print(f"timeout={client.timeout}s max_retries={client.max_retries}")
print(f"retries on: {sorted(type(client).RETRYABLE_STATUS_CODES)}")timeout=30s max_retries=3
retries on: [429, 502, 503, 504]Retries apply to the HTTP request, not to a simulation. A job that fails in the engine returns a successful HTTP response with has_errors: true — read the job status and fetch get_job_logs(job_id) for the reason.
Client configuration
| Argument | Environment variable | Default |
|---|---|---|
| api_key | SIMUDYNE_API_KEY | required — ValueError if neither is set |
| base_url | SIMUDYNE_BASE_URL | https://pulse-api.simudyne.com |
| timeout | — | 30 seconds |
| max_retries | — | 3 |
from simudyne import PulseABM
# Both arguments fall back to the environment when omitted, so this is
# equivalent to PulseABM() with SIMUDYNE_API_KEY and SIMUDYNE_BASE_URL set.
client = PulseABM(
api_key="pk_live_...",
base_url="https://pulse-api.simudyne.com",
)The underlying requests.Session is exposed as client.session with the auth header already applied, which is how you reach anything the SDK does not wrap — see the API reference.
Handling errors by kind
Most code only needs to separate “my fault” from “their fault”:
from simudyne import PulseAPIError
def fetch(sim_id):
try:
return client.simulation.get_sim_data(sim_id)
except PulseAPIError as exc:
if exc.status_code == 404:
return None # not there; carry on
if exc.status_code == 403:
raise SystemExit(f"tier: {exc.detail}") # never going to work
if exc.status_code == 429:
quota = client.profile.downloads()
raise SystemExit(f"quota exhausted, {quota['window_hours']}h window")
raise # anything else is a real bug
sim_id = client.simulation.list_cached()["simulations"][0]["example_sim_id"]
print(fetch("nope") is None, fetch(sim_id) is not None)