> Source: https://wexa.ai/docs/get-started/errors-and-retries

# 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

1. A call runs through the lifecycle stages `S1:authenticate`, `S2:resolve-scope`, `S3:rate-quota`, `S4:validate-input`, `S5:policy`, `S6:approval`, `S7:dry-run` and `S8:execute`, in order.
2. The first stage that refuses answers with an error body whose `error` is that stage id.
3. The SDK maps the body to a class: first by the `error` string, then by the HTTP status, then by a fallback. First match wins.
4. 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.

```json
{
  "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.

```text
HTTP/1.1 400 Bad Request

{"error":"invalid_request","error_description":"invalid character 'o' looking for beginning of object key string"}
```

```text
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"}
```

```text
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](/docs/get-started/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".

**Fetch a lifecycle record**

**REST API**

```bash
curl -sS https://fabric.wexa.ai/v1/lifecycles/qlc_000002 \
  -H "Authorization: Bearer $WEXA_API_KEY"
```

**TypeScript SDK**

```ts
import { Fabric } from '@wexa-fabric/sdk'

const fabric = new Fabric()
const record = await fabric.lifecycle('qlc_000002')
```

**Python SDK**

```python
from wexa import Fabric

fabric = Fabric()
record = fabric.lifecycle("qlc_000002")
```

An SDK error already carries these ids. Catch it and read them:

```ts
try {
  await fabric.queryContext({ query: 'CREATE (n)' })
} catch (e) {
  if (WexaError.isWexaError(e)) console.error(e.stage, e.lifecycleId, e.traceId)
}
```

```python
try:
    fabric.query_context(query="CREATE (n)")
except WexaError as e:
    print(e.stage, e.lifecycle_id, e.trace_id)
```

```json
{
  "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:

```text
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:

**Catch by class**

**TypeScript SDK**

```ts
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
  }
}
```

**Python SDK**

```python
from wexa import (
    ApprovalRequired, PolicyDenied, ForbiddenError, ValidationError,
    QuotaExceeded, WexaError,
)

try:
    fabric.save_context(nodes=nodes)
except ApprovalRequired as e:
    checkpoint(e.approval_id, e.resume_token)   # durable, before you wait
except PolicyDenied:
    pass  # S5: a rule refused it. Terminal.
except ForbiddenError:
    pass  # S2: mint a credential that holds the grant.
except ValidationError:
    pass  # Your payload.
except QuotaExceeded as e:
    pass  # S3: wait e.retry_after seconds, or pass retry=True.
except WexaError:
    pass  # Everything else the gateway sent.
except OSError:
    pass  # The request never reached the gateway.
```

`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.

**Retry a read**

**REST API**

```bash
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"}'
```

**TypeScript SDK**

```ts
await fabric.queryContext({ query }, { retry: true })   // safe: it is a read
```

**Python SDK**

```python
fabric.query_context(query=query, retry=True)   # safe: it is a read
```

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.

```bash
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"}'
```

```text
X-Trace-Id: 4bf92f3577b34da6a3ce929d0e0e4736
```

On 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.

**Resume with a saved token**

**TypeScript SDK**

```ts
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.
  }
}
```

**Python SDK**

```python
try:
    fabric.resume("save_context", load_token(), retry=True)
except WexaError as e:
    if e.resume_spent:
        pass  # 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](/docs/administration/usage-quota-and-billing#when-a-ceiling-is-reached) covers configuring the windows.

**More**

[How every tool call works](/docs/sdks-and-apis/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](/docs/governance/audit-and-lifecycle) covers what the stages are and what is recorded about each. [Policy and approvals](/docs/governance/policy-and-approvals) covers what makes a call gate in the first place.
