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
- Check the HTTP status. A failure in front of the service can be non-JSON.
- Read
error.codeanderror.retryable. Never branch onerror.message; it is for people. - If
retryableistrue, waiterror.retry_after_swhen it is present, then send the identical request with the samerequest_id. - If
retryableisfalse, fix the cause from the table below. Then send the work with a newrequest_id. - 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"}}| Field | Meaning |
|---|---|
code | One of the codes below. The HTTP status follows it. |
message | A readable explanation. It can change; don't parse it. |
retryable | Whether the identical request may succeed later. |
retry_after_s | Seconds to wait, when the service knows. /v1 replies carry it in the body only, not as a Retry-After header. |
details | Facts for your code: path for a bad field, reason, limit, amounts, the conflicting request_id. |
card | Sometimes 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
| Code | HTTP | Retry | Meaning, and what to do |
|---|---|---|---|
unauthenticated | 401 | no | Missing, expired or revoked credential. Get a current one; keep reading the same jobs and sessions. |
scope_denied | 403 | no | The credential lacks the scope (details.required or details.scope) or the project. Use a credential that has it. |
invalid_request | 422 | no | The request doesn't match the schema, or names something unusable. details.path names the field. Fix it. |
not_found | 404 | no | No such session, turn, approval, job, artifact, tool or route in this workspace. Another workspace's ids are always not_found. |
conflict | 409 | no | The 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_conflict | 409 | no | A tool call reused a request_id for different work, another project or another tool. Nothing started. Use a new request_id. |
request_id_mismatch | 409 | no | A session write reused a request_id with a different body. Nothing ran. details name the original turn. |
controller_conflict | 409 | no | Another controller holds the session, or your token was replaced. claim with takeover: true if the session is yours. |
session_closed | 409 | no | The session is closed, or its deadline_at passed. Create a new session with the same project. |
quote_expired | 409 | no | The quote expired or was bound to other work. Quote again and use the new quote_id. |
model_pin_conflict | 409 | no | Inside a turn, a call made a pinned kind of media with another model. Use the pinned model. |
insufficient_credits | 402 | no | The 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_exceeded | 402 | no | A 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_refused | 403 | no | A sexual or nude depiction of a real person or of a minor. Other content about real people or minors is not refused. |
consent_required | 403 | no | Adult (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_refused | 403 | no | The workspace's model and route policy forbids this route, model, vendor or class. Use one the policy allows. |
destination_unauthorized | 403 | no | Your storage bucket refused the export's credentials. Fix the destination's credentials. |
not_deliverable | 409 | no | The file can't count as delivered. details.reason says why (see below). Fix the file and deliver again. |
too_large | 413 | no | A JSON body over 2 MB, or an upload over 5 GiB. Upload media as bytes. |
content_removed | 451 | no | The file was removed after a legal or safety request and can't be uploaded or published. |
invalid_graph | 422 | no | A Comfy graph is malformed or no runtime admits it. The message names what to fix. See Graph portability. |
invalid_timeline | 422 | no | A compose timeline fails its schema; details.path names where. |
unsupported_op | 422 | no | edit can't apply that op to this source. |
storage_budget_exceeded | 422 | no | The service's storage budget can't take new bytes now. Contact support. |
quota_exceeded | 429 | no | The workspace's storage quota is full. details give the limit and the bytes needed. Delete files or ask for a larger quota. |
rate_limited | 429 | usually | Over 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_unavailable | 503 | no | The route, model or graph can't run here (details.reason, for example no_rate or not_ported). Choose another. |
busy | 503 | yes | The work queue or the service is busy. Wait retry_after_s, then retry with the same request_id. |
policy_unavailable | 503 | yes | A money, likeness or delivery check couldn't decide. Retry with the same request_id. |
sandbox_unavailable | 503 | yes | The run sandbox couldn't start. Retry with the same request_id. |
upstream_failed | 502 | per retryable | A 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. |
timeout | 504 | usually | No 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_error | 500 | no | The 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:
| Reason | Meaning |
|---|---|
spec | The file misses its destination's spec. failures lists each check. |
draft_tier | The file is a compose draft. Render the final. |
content | Nudity or sexual content in work not rated 18+. frames gives the times. |
not_owned | The workspace doesn't own that file. |
role | The role isn't delivery_video, delivery_image, delivery_audio or delivery_set. |
artifact | A value isn't a sha256. |
nothing_selected, too_many | No files were named, or more than 100. |
deliverable_id | In a session turn, the id is missing or wasn't declared by the turn. |
deliverable_kind, deliverable_format | The role or file type doesn't match the deliverable's kind or formats. |
Errors outside the /v1 envelope
| Where | What you get |
|---|---|
| A write from a web page on another site | 403 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 route | 413 body_limit. On /v1 the same limit is too_large. |
| An older operation this service no longer serves | 501 operation_not_ported. Retrying won't help; use the tools or the session API. |
| A name outside the older catalog | 404 {"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
completedwithoutcome.statuspartialorstoppedandreason: "budget". What was made is kept. - A turn whose agent doesn't start within 120 seconds, or stops answering for 180 seconds, ends
failedwith a retryabletimeout. - A turn ends
failedonly when it can't continue. Retry it as a new turn with a newrequest_id.
Limits
| Limit | Value | Code |
|---|---|---|
| JSON request body | 2 MB | too_large |
| One upload | 5 GiB | too_large |
| Request rate | per credential, on tool calls and older operations | rate_limited, details.limit: "credential_rate" |
| Agent turns running at once | 24 per workspace in production | rate_limited, details.limit: "hosted_turns_per_workspace", retry_after_s: 30 |
Tool calls per agent run or run sandbox | 10 per second of requested output media, at least 300 | rate_limited, details.limit: "tool_call_budget", not retryable within that run |
| Turn text, attachments, deliverables | 64,000 characters, 40, 50 | invalid_request |
Takes per generate | 8 | invalid_request |
| Webhook endpoints | 20 per workspace | conflict |
| Credits | your balance and the caps you set; no other per-run credit cap | insufficient_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
| Wait | Longest |
|---|---|
Session events, wait_s | 25 seconds |
Session event stream (text/event-stream) | 300 seconds, then reconnect with Last-Event-ID |
read of a job, wait_s | 85 seconds |
compose, wait_s | 40 seconds, then the job |
| An approval | 15 minutes, then it counts as declined |
| A signed download link | 3,600 seconds |
| An upload link | 600 seconds |
| A quote | 1,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_mismatchon the session API,request_conflicton 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}, orreadof 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 afterretry_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:
writewith{"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.