Errors and retries
A Wexa refusal tells you three things: the HTTP status, which stage of the governed lifecycle refused, and which lifecycle record to read for the rest. The SDKs turn the body into typed errors with retryability already decided, so you do not write the retry loop yourself.
Anyone calling Wexa needs this page, over raw HTTP or through the TypeScript or Python SDK. Every class extends WexaError, so one catch covers everything the library can throw.
The error body
- A call runs through the lifecycle stages
S1:authenticate,S2:resolve-scope,S3:rate-quota,S4:validate-input,S5:policy,S6:approval,S7:dry-runandS8:execute, in order. - The first stage that refuses answers with an error body whose
erroris that stage id. - The SDK maps the body to a class: first by the
errorstring, then by the HTTP status, then by a fallback. First match wins. - You read the class (or the status), decide whether the failure can succeed on a second attempt, and fetch the lifecycle record if it is not self-explanatory.
{
"error": "S4:validate-input",
"error_description": "cypher rejected: query must start with a read clause (MATCH/OPTIONAL MATCH/WITH/UNWIND/RETURN/CALL), got \"CREATE\"",
"lifecycle_id": "qlc_000002"
}| Field | Meaning |
|---|---|
error | A stable code. For a lifecycle refusal it is the stage id: S3:rate-quota, S4:validate-input, S5:policy, S6:approval, S8:execute. Outside the lifecycle it is a name: invalid_token, invalid_request, not_found, conflict, unconfigured, unavailable. |
error_description | Human-readable detail. Safe to log; do not parse. |
lifecycle_id | Present once the call has a lifecycle. Absent on a 401, which is refused before one exists. |
Two response headers matter. Retry-After appears on a 429 and carries the real remaining seconds of the quota window. X-Trace-Id appears when the request carried a traceparent header or the deployment has its tracing exporter configured; send your own traceparent and the gateway echoes that trace id back.
Status codes
| Status | When | Retry? |
|---|---|---|
400 | The body is not valid JSON, or a required field is missing. | No |
401 | Missing, malformed, revoked or wrong-kind credential. | No |
403 | The credential lacks the grant (S2:resolve-scope), a policy rule refused (S5:policy), or a resume token was rejected (S6:approval). | No |
404 | No such approval, flow or execution, or the path names no tool. | No |
405 | Right path, wrong method. Tools are POST only; the response carries Allow. | No |
409 | State conflict: an approval no longer pending, an agent with no promoted version. | Re-read state first |
422 | Parsed but failed validation or a dry run (S4:validate-input, S7:dry-run). | No |
429 | Over the request window (S3:rate-quota). Carries Retry-After. | Yes |
502 | A downstream service refused or was unreachable (S8:execute), or the deployment is misconfigured (unconfigured). | Only when unlabelled |
503 | unavailable, a deployment fault; or S8:knowledge-base-unavailable. | No for unavailable; later for the knowledge base |
504 | An upstream deadline was exceeded. | Yes |
Error classes
Both SDKs share one base class, WexaError. A tool page lists only the errors specific to that tool; these classes are reachable from every tool: AuthError, ForbiddenError, PolicyDenied, ValidationError, QuotaExceeded, UpstreamError and TimeoutError.
| Class | Status | Raised when |
|---|---|---|
AuthError | 401 | The credential is missing, malformed or revoked (invalid_token, unauthorized). |
ForbiddenError | 403 | The credential lacks the grant the tool needs, or its scope does not cover the project (S2). |
PolicyDenied | 403 | A policy rule refused the call outright (S5). Subclasses ForbiddenError. The refusal names the rule. |
ValidationError | 400 / 422 | An argument is missing, of the wrong type, or one the platform binds itself; malformed body, rejected Cypher or a failed dry run (S4, S7). Also raised client-side, before any request, for a server-bound argument. |
ApprovalRequired | 202 | A person must approve the call before it runs. Carries the approval id and a resume token. |
ApprovalError | 403 | The resume token is bad, expired, rejected or spent, or the wait for the approval failed (S6). |
QuotaExceeded | 429 | The project or organization is over its request window or its credit balance (S3). |
NotFound | 404 | An id or name in the arguments matches nothing in your scope: an approval, flow, execution or object. |
ConflictError | 409 | The change collides with current state: an approval no longer pending, or an agent not ready. |
UpstreamError | 502 / 503 | A downstream service refused or was unreachable (S8). |
TimeoutError | 504 | An upstream deadline was exceeded. Extends UpstreamError. |
ConfigurationError | 502 / 503 | The tool is not available to you (unconfigured, unavailable), a deployment fault. Retrying will not help. |
TransportError | none | Nothing reached the gateway (TypeScript only). |
Retry a failed call
Only two failures are worth retrying without changing anything: 429 and an unlabelled 5xx. Everything else refuses identically the second time.
Nothing retries unless you ask. Without an idempotency key a retried run-agent starts a second real run, so the SDK leaves that decision to you. On a 429 wait the Retry-After seconds, or pass retry: true (retry=True in Python) on a read.
Where the two SDKs differ
| TypeScript | Python | |
|---|---|---|
| Nothing reached the gateway | TransportError, a WexaError | urllib's OSError (a URLError), which is not a WexaError; catch both |
| The 504 class | TimeoutError | TimeoutError_, underscored to dodge the builtin; it subclasses UpstreamError rather than standing alone |
| Predicates | isRetryable(e), isResumeRetryable(e) | isinstance(e, RETRYABLE), isinstance(e, RESUME_RETRYABLE) |
traceId on a 202 | Carried | Dropped, since a pending approval has no trace id |
How an error is chosen
The gateway answers a refused call with an error string and an HTTP status. Both SDKs turn that pair into a typed exception, choosing on the error string first and the status second, so the same refusal becomes the same class in both languages.
On REST and on the Wexa MCP server there is no typed error: REST gives you the status and the error / error_description pair directly, and the MCP server reports a refusal as a JSON-RPC error or as isError: true with the message in content[0].text.
The mapping tiers are: the error string (invalid_token and unauthorized to AuthError; harness_unreachable and data_service_unreachable to UpstreamError; unconfigured and unavailable to ConfigurationError; stage ids to their class), then the status (400 and 422 ValidationError, 401 AuthError, 403 ForbiddenError, 404 NotFound, 409 ConflictError, 429 QuotaExceeded, 504 TimeoutError), then any 5xx as UpstreamError and anything else as a bare WexaError.
Only QuotaExceeded, UpstreamError and TimeoutError are retryable; only QuotaExceeded is resume-retryable. Every other class, including TransportError and ApprovalRequired, is neither.
Read a refusal
Each of these is real output from a running gateway.
HTTP/1.1 400 Bad Request
{"error":"invalid_request","error_description":"invalid character 'o' looking for beginning of object key string"}HTTP/1.1 422 Unprocessable Entity
{"error":"S4:validate-input","error_description":"cypher rejected: query must filter on project_id: use `project_id = $project_id` for this project, or `project_id IN $project_ids` to include projects you were granted","lifecycle_id":"qlc_000003"}HTTP/1.1 429 Too Many Requests
Retry-After: 14
{"error":"S3:rate-quota","error_description":"quota exceeded for project proj_c10 (retry after 14s)","lifecycle_id":"qlc_000152"}A 401 for a missing credential is shown on Authentication. To see which stage refused, fetch the lifecycle record: it lists every stage the call reached, in order, with the refusing one marked failed. The first failing stage tells "my credential is wrong" from "my payload is wrong".
curl -sS https://fabric.wexa.ai/v1/lifecycles/qlc_000002 \
-H "Authorization: Bearer $WEXA_API_KEY"An SDK error already carries these ids. Catch it and read them:
try {
await fabric.queryContext({ query: 'CREATE (n)' })
} catch (e) {
if (WexaError.isWexaError(e)) console.error(e.stage, e.lifecycleId, e.traceId)
}try:
fabric.query_context(query="CREATE (n)")
except WexaError as e:
print(e.stage, e.lifecycle_id, e.trace_id){
"lifecycle_id": "qlc_000002",
"tool": "query-context",
"status": "denied",
"stages": [
{ "stage": "S1:authenticate", "status": "ok", "detail": "credential verified by transport (api_key)" },
{ "stage": "S2:resolve-scope", "status": "ok", "detail": "org=org_c10 project=proj_c10 role=OWNER" },
{ "stage": "S3:rate-quota", "status": "ok", "detail": "within limits" },
{ "stage": "S4:validate-input", "status": "failed", "detail": "cypher rejected: query must start with a read clause …" }
]
}Every SDK error carries stage, status, lifecycleId (lifecycle_id in Python), traceId, raw (the full decoded body; on a TransportError, the original thrown error), retryAfter (seconds from Retry-After, clamped to [0, 60]) and resumeSpent. stage is undefined for refusals outside the lifecycle: a 401, a 404, unconfigured. status is undefined when nothing reached the gateway.
Both ids are appended to the message, so a bare log line carries them; quote them in a support request:
quota exceeded for project proj_c10 (retry after 35s) [lifecycle_id=qlc_000157 trace_id=36088b71f728852753f230188485b0dd]Class hierarchy and handling by class
Handle errors by class
Every class extends WexaError, so one catch covers everything the library can throw.
The inheritance decides what retries: Python's policy is isinstance(e, (UpstreamError, QuotaExceeded)) and the TypeScript SDK keeps exactly that membership. TimeoutError retries because it extends UpstreamError; ConfigurationError does not, despite a 5xx status, because it extends WexaError directly.
ApprovalRequired and ApprovalError are siblings: the first means a person has not decided yet, the second means the token that would have resumed the call is no longer good.
WexaError base
├── AuthError 401 invalid_token, unauthorized
├── ForbiddenError 403 the credential lacks the grant, or S2 refused the scope
│ └── PolicyDenied 403 S5 — a policy rule refused the call outright
├── ValidationError 400/422 malformed body, rejected Cypher, failed dry run (S4/S7)
├── ApprovalError 403 S6 — the resume token is bad, expired, rejected or spent
├── QuotaExceeded 429 S3 — over the request window
├── NotFound 404 no such approval, flow or execution in your scope
├── ConflictError 409 approval not pending, or agent not ready
├── UpstreamError 502/503 S8 — a downstream service refused or was unreachable
│ └── TimeoutError 504 an upstream deadline was exceeded
├── ConfigurationError 502/503 unconfigured, unavailable — a deployment fault
├── TransportError nothing reached the gateway (TypeScript only)
└── ApprovalRequired 202 a person must approve before the call proceeds
Some classes vary by tool: NotFound is reachable from tools that take an id or a name that must already resolve to an object; ApprovalRequired from consequential tools, which a policy may hold for a person (the project's write gate, off by default, also holds writes when an API key or SDK session calls them); ConfigurationError from plane-gated tools, which the always-registered tools cannot reach. Each tool page's error table is that tool's slice.
Order handlers from specific to general:
import {
ApprovalRequired, PolicyDenied, ForbiddenError, ValidationError,
QuotaExceeded, WexaError,
} from '@wexa-fabric/sdk'
try {
await fabric.saveContext({ nodes })
} catch (e) {
if (e instanceof ApprovalRequired) {
await checkpoint(e.approvalId, e.resumeToken) // durable, before you wait
} else if (e instanceof PolicyDenied) {
// S5: a rule refused it. Terminal; a different credential will not help.
} else if (e instanceof ForbiddenError) {
// S2: mint a credential that holds the grant.
} else if (e instanceof ValidationError) {
// Your payload. e.stage says whether the gateway or the SDK rejected it.
} else if (e instanceof QuotaExceeded) {
// S3: wait e.retryAfter seconds, or let retry: true do it.
} else if (WexaError.isWexaError(e)) {
// Everything else the gateway sent, plus TransportError.
} else {
throw e
}
}WexaError.isWexaError(e) is a string-tag check, not a prototype check. Prefer it to instanceof WexaError when two copies of the package could end up in one dependency tree, where instanceof fails. It has no Python equivalent.
Retry details
The gateway accepts a top-level idempotency_key and runs the call once however often it is sent. The Python SDK sends it and then defaults retries on; the TypeScript SDK does not yet expose it.
retries: 3 on the constructor means three attempts in total, not three extra ones.
curl -sS -X POST https://fabric.wexa.ai/v1/query-context \
-H "Authorization: Bearer $WEXA_API_KEY" -H 'Content-Type: application/json' \
-d '{"query":"MATCH (c:Customer) WHERE c.project_id = $project_id RETURN c.name AS name LIMIT 5"}'Over REST the retry loop is yours to write. To correlate a call, send your own traceparent; the gateway echoes the trace id as X-Trace-Id, with a Server-Timing: traceparent;desc="…" header carrying the same id.
curl -sS -i -X POST https://fabric.wexa.ai/v1/docs \
-H "Authorization: Bearer $WEXA_API_KEY" -H 'Content-Type: application/json' \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
-d '{"topic":"governance"}'X-Trace-Id: 4bf92f3577b34da6a3ce929d0e0e4736On a 429 the gateway sends the quota window's real remaining seconds. Returning before the window resets only spends another 429. A small random offset is added, so a fleet told to wait the same number of seconds does not return in lockstep.
With no header, back off with full jitter, a uniform draw from [0, 2^attempt) seconds, capped at 60, the length of the quota window. Retry-After is parsed strictly: the gateway sends seconds, and an HTTP-date or a non-integer is ignored rather than misparsed into a wrong sleep.
Against a live gateway with the project's 120-request window exhausted, a call without retry raised QuotaExceeded (status 429, stage S3:rate-quota, retryAfter 35), and the same call with retry: true succeeded after 35.9 seconds.
A 502 carrying S8:execute, harness_unreachable, data_service_unreachable or upstream_unreachable is a downstream service having a bad moment: retry it. A 502 or 503 carrying unconfigured or unavailable is a deployment fault, a service that is not wired up at all. Retrying never fixes it, so tell an operator.
The SDKs encode this by mapping the second group to ConfigurationError, excluded from the retry set despite its 5xx status. That works because tier two of the mapping deliberately omits 502 and 503: an unlabelled 502 falls to the fallback and becomes a retryable UpstreamError, while a 503 that names itself unconfigured is caught in tier one.
A 503 carrying S8:knowledge-base-unavailable comes from knowledge-base-add, knowledge-base-retrieve or delete-kb-document when the knowledge base's document store is down or rejects the platform's own credential. Your request was fine, and the message never carries the store's internal error. Both SDKs raise it as UpstreamError.
Retry later rather than in a tight loop: an outage clears on its own, a rejected credential waits for an operator.
TransportError does not retry: nothing reached the gateway, so the SDK cannot tell a wrong workspace URL from a network blip. ApprovalRequired is built in the transport layer, which holds the approval object and tool name its constructor needs, so it appears in none of the mapping tables.
Resume an approved call
Resume an approved call
A call that gates for approval throws ApprovalRequired carrying a single-use resumeToken. When you send that token back, the gateway redeems it between S3 and S4.
S1 authenticate → S2 resolve-scope → S3 rate-quota → [REDEEM] → S4 validate-input → S5 policy → … → S8 execute
↑ ↑
refused here: token intact refused here: token gone
A refusal at S3 arrives before redemption and the token survives. Every refusal after it means the token is burned, the write did not land, and re-running needs a fresh human approval.
So a plain retry is not a resume retry: only QuotaExceeded is resume-retryable. On the resume path the SDK sets resumeSpent to true when the failure is not resume-retryable and rethrows at once, rather than retrying into a refusal that can only repeat.
try {
await fabric.resume('save_context', await loadToken(), { retry: true })
} catch (e) {
if (e instanceof WexaError && e.resumeSpent) {
// The approval is gone. Re-request it; do not loop.
}
}Sending a token on an exhausted quota produced exactly this, live: ApprovalError, resumeSpent true.
Quota defaults
The defaults are 120 requests per project and 600 per organization in a sliding 60-second window, and 0 means unlimited. POST /v1/docs reports your live headroom as "quota": { "org_window_max": 600, "org_window_used": 12, "project_window_max": 120, "project_window_used": 12 }; usage, quota and billing covers configuring the windows.
More
How every tool call works covers what every call shares, and each tool page lists only the errors specific to that tool. Lifecycle and audit covers what the stages are and what is recorded about each. Policy and approvals covers what makes a call gate in the first place.