Errors, limits and safe retries

Every error code with its HTTP status and whether to retry, the limits behind them, and how to retry without duplicating work.

Updated

Every refusal from the /v1 API names a code from one closed list, says whether the same request may be sent again, and keeps the work you already paid for safe. This page lists every code, the limits behind them, and how to retry without starting the same work twice.

Handle an error

  1. Check the HTTP status. A failure in front of the service can be non-JSON.
  2. Read error.code and error.retryable. Never branch on error.message; it is for people.
  3. If retryable is true, wait error.retry_after_s when it is present, then send the identical request with the same request_id.
  4. If retryable is false, fix the cause from the table below. Then send the work with a new request_id.
  5. If you don't know whether the request arrived (a network error, your own timeout, a 5xx), read the original before you send anything new. See Keep request identity stable.

Treat a code you don't recognize as final unless retryable is true.

The error object

Every /v1 reply is the envelope {ok, data, error, job, cost, hints}. A refusal has ok: false, data: null and:

{"code": "rate_limited", "message": "too many requests for this credential",
 "retryable": true, "retry_after_s": 0.4, "details": {"limit": "credential_rate"}}
FieldMeaning
codeOne of the codes below. The HTTP status follows it.
messageA readable explanation. It can change; don't parse it.
retryableWhether the identical request may succeed later.
retry_after_sSeconds to wait, when the service knows. /v1 replies carry it in the body only, not as a Retry-After header.
detailsFacts for your code: path for a bad field, reason, limit, amounts, the conflicting request_id.
cardSometimes a wm://card/{id} URI with advice for a known failure pattern. read it.

A 401 also carries WWW-Authenticate: Bearer realm="wavemaker", with the OAuth protected-resource metadata for MCP hosts.

Codes

CodeHTTPRetryMeaning, and what to do
unauthenticated401noMissing, expired or revoked credential. Get a current one; keep reading the same jobs and sessions.
scope_denied403noThe credential lacks the scope (details.required or details.scope) or the project. Use a credential that has it.
invalid_request422noThe request doesn't match the schema, or names something unusable. details.path names the field. Fix it.
not_found404noNo such session, turn, approval, job, artifact, tool or route in this workspace. Another workspace's ids are always not_found.
conflict409noThe state changed: a stale expected_revision or if_revision, an approval already decided, a compose request_id reused for other work. Re-read, then decide.
request_conflict409noA tool call reused a request_id for different work, another project or another tool. Nothing started. Use a new request_id.
request_id_mismatch409noA session write reused a request_id with a different body. Nothing ran. details name the original turn.
controller_conflict409noAnother controller holds the session, or your token was replaced. claim with takeover: true if the session is yours.
session_closed409noThe session is closed, or its deadline_at passed. Create a new session with the same project.
quote_expired409noThe quote expired or was bound to other work. Quote again and use the new quote_id.
model_pin_conflict409noInside a turn, a call made a pinned kind of media with another model. Use the pinned model.
insufficient_credits402noThe balance can't cover the hold. details give the amounts. With details.reason: "starter_credits_need_payment", starter credits can't be spent through an API key or an app you connected until the workspace has paid for credits or a plan. Top up, then send with a new request_id.
cap_exceeded402noA cap would be passed: the call's or turn's max_microcredits, the session cap, or a workspace or member spending cap. details give the cap. Raise it or send smaller work.
policy_refused403noA sexual or nude depiction of a real person or of a minor. Other content about real people or minors is not refused.
consent_required403noAdult (18+) work names a real person, uses an outside image or video that shows a person, or uses a source the service can't trace. Adult work can only use original characters made on Wavemaker.
workspace_policy_refused403noThe workspace's model and route policy forbids this route, model, vendor or class. Use one the policy allows.
destination_unauthorized403noYour storage bucket refused the export's credentials. Fix the destination's credentials.
not_deliverable409noThe file can't count as delivered. details.reason says why (see below). Fix the file and deliver again.
too_large413noA JSON body over 2 MB, or an upload over 5 GiB. Upload media as bytes.
content_removed451noThe file was removed after a legal or safety request and can't be uploaded or published.
invalid_graph422noA Comfy graph is malformed or no runtime admits it. The message names what to fix. See Graph portability.
invalid_timeline422noA compose timeline fails its schema; details.path names where.
unsupported_op422noedit can't apply that op to this source.
storage_budget_exceeded422noThe service's storage budget can't take new bytes now. Contact support.
quota_exceeded429noThe workspace's storage quota is full. details give the limit and the bytes needed. Delete files or ask for a larger quota.
rate_limited429usuallyOver the credential's request rate (details.limit: "credential_rate") or the workspace's running agent turns ("hosted_turns_per_workspace"): wait retry_after_s and retry with the same request_id. Over a run's tool-call budget ("tool_call_budget"): not retryable within that run.
route_unavailable503noThe route, model or graph can't run here (details.reason, for example no_rate or not_ported). Choose another.
busy503yesThe work queue or the service is busy. Wait retry_after_s, then retry with the same request_id.
policy_unavailable503yesA money, likeness or delivery check couldn't decide. Retry with the same request_id.
sandbox_unavailable503yesThe run sandbox couldn't start. Retry with the same request_id.
upstream_failed502per retryableA provider, worker or the agent failed. When retryable is true, retry; when a turn or job ended with it, retry as new work with a new request_id.
timeout504usuallyNo answer in time. When retryable is true, retry the request with the same request_id. A compose render that ran out of time returns retryable: false; render again with a new request_id. As a failed turn's error, post a new turn with a new request_id.
internal_error500noThe service failed unexpectedly. Re-read the state (the session, the request, the job). If the request isn't there, resend it with the same request_id.

Why a delivery was refused

not_deliverable carries details.reason:

ReasonMeaning
specThe file misses its destination's spec. failures lists each check.
draft_tierThe file is a compose draft. Render the final.
contentNudity or sexual content in work not rated 18+. frames gives the times.
not_ownedThe workspace doesn't own that file.
roleThe role isn't delivery_video, delivery_image, delivery_audio or delivery_set.
artifactA value isn't a sha256.
nothing_selected, too_manyNo files were named, or more than 100.
deliverable_idIn a session turn, the id is missing or wasn't declared by the turn.
deliverable_kind, deliverable_formatThe role or file type doesn't match the deliverable's kind or formats.

Errors outside the /v1 envelope

WhereWhat you get
A write from a web page on another site403 origin_rejected in the older {schema: "wmlab.result/1", ok: false, error} shape. Call the API from your server.
Older operations (/api/v1/ops/{name}) and file routes (/api/v1/files/..., /api/v1/inputs)The older wmlab.result/1 envelope with {code, message, details}: unauthorized (401), forbidden (403), request_invalid, input_invalid or preview_invalid (422), codes ending in _missing, such as resource_missing or artifact_missing (404), quota_exceeded (429, also the older name for rate_limited, with a Retry-After header), service_busy or release_draining (503, with a Retry-After header), internal_error (500).
A body over 2 MB on an older route413 body_limit. On /v1 the same limit is too_large.
An older operation this service no longer serves501 operation_not_ported. Retrying won't help; use the tools or the session API.
A name outside the older catalog404 {"detail": "Not Found"} on /api/v1/ops/{name}.

Errors inside a running turn

Failures while the hosted agent works, such as a provider error or a refused tool call, are the agent's to handle. They appear as tool.completed events with ok: false and error_code; the turn carries on.

  • A tool call or model call refused for money ends the turn completed with outcome.status partial or stopped and reason: "budget". What was made is kept.
  • A turn whose agent doesn't start within 120 seconds, or stops answering for 180 seconds, ends failed with a retryable timeout.
  • A turn ends failed only when it can't continue. Retry it as a new turn with a new request_id.

Limits

LimitValueCode
JSON request body2 MBtoo_large
One upload5 GiBtoo_large
Request rateper credential, on tool calls and older operationsrate_limited, details.limit: "credential_rate"
Agent turns running at once24 per workspace in productionrate_limited, details.limit: "hosted_turns_per_workspace", retry_after_s: 30
Tool calls per agent run or run sandbox10 per second of requested output media, at least 300rate_limited, details.limit: "tool_call_budget", not retryable within that run
Turn text, attachments, deliverables64,000 characters, 40, 50invalid_request
Takes per generate8invalid_request
Webhook endpoints20 per workspaceconflict
Creditsyour balance and the caps you set; no other per-run credit capinsufficient_credits, cap_exceeded

A turn posted behind its own session's running turn queues instead of counting toward the workspace's running turns.

Timeouts and waiting

WaitLongest
Session events, wait_s25 seconds
Session event stream (text/event-stream)300 seconds, then reconnect with Last-Event-ID
read of a job, wait_s85 seconds
compose, wait_s40 seconds, then the job
An approval15 minutes, then it counts as declined
A signed download link3,600 seconds
An upload link600 seconds
A quote1,800 seconds

Set your client's timeout above the wait you ask for; 100 seconds covers every wait_s above. A timeout on your side stops your observation, not the work. It never cancels a job or a turn.

Keep request identity stable

A request_id names one piece of work. Choose it before you send, save it, and reuse it only for that work.

  • Same id, same body: a replay. You get the original outcome, and nothing runs or is charged twice. Session replies carry Wavemaker-Replayed: true.
  • Same id, different work: request_id_mismatch on the session API, request_conflict on the tools. Nothing starts.
  • Uncertain outcome: after a network error, your own timeout or a 5xx, resend the identical request with the same id, or read the original (GET /v1/sessions/{id}/requests/{request_id}, or read of the job). Never invent a new id for the same work.
  • Retryable refusal (rate_limited, busy, policy_unavailable, timeout, sandbox_unavailable): nothing was recorded. Retry with the same id after retry_after_s.
  • Recorded refusal of a session turn (insufficient_credits, cap_exceeded, quote_expired, policy_refused, consent_required): a replay returns the same error. Fix the cause and use a new id.
  • Work that ended `failed`: the id keeps returning that failure. Retry as new work with a new id.

Session request_ids match ^[A-Za-z0-9._:-]{1,200}$. Tool calls don't change a job's identity when only max_microcredits, quote_id, rush or wait_s change.

Separate watching from stopping

A wait that ends with nothing new means you stopped watching. It doesn't mean the job failed, and it cancels nothing.

  • To stop a job: write with {"uri": "wm://job/{id}", "cancel": true}.
  • To stop a session turn: POST /v1/sessions/{id}/interrupt.
  • To end a session: POST /v1/sessions/{id}/close.

See Quote and run work for job states and Recover interrupted work for the web app.

Related guidance