> Source: https://wexa.ai/docs/surfaces/rest/errors-and-retries

# REST errors and retries

A Wexa refusal tells you three things: which HTTP status, which *stage* of the governed lifecycle
refused, and which lifecycle record to read for the rest. Learn the error body once and every route
on this surface becomes readable.

The SDKs turn everything on this page into typed exceptions with retryability already decided. If
you are using one, read [error handling](/docs/surfaces/typescript/error-handling) instead — this
page is for callers working over raw HTTP.

## The error body

```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 straight back:

```bash
curl -s -i -X POST "$BASE/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
Server-Timing: traceparent;desc="4bf92f3577b34da6a3ce929d0e0e4736"
```

## 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`), or 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 that is no longer pending, an agent with no promoted version. | Re-read state first |
| `422` | The request 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. | No |
| `504` | An upstream deadline was exceeded. | **Yes** |

## Worked refusals

Each of these is real output from a running gateway.

**No credential**

```text
HTTP/1.1 401 Unauthorized
Www-Authenticate: Bearer resource_metadata="http://localhost:7123/.well-known/oauth-protected-resource"

{"error":"invalid_token","error_description":"missing bearer credential"}
```

**Malformed JSON**

```text
HTTP/1.1 400 Bad Request

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

**A write clause sent to a read-only tool**

```text
HTTP/1.1 422 Unprocessable Entity

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

**A query that does not constrain itself to a project**

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

**Over quota**

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

## Reading the lifecycle record

When an error is not self-explanatory, fetch its lifecycle. It lists every stage the call reached,
in order, with the one that refused marked `failed`:

```bash
curl -s "$BASE/v1/lifecycles/qlc_000002" -H "Authorization: Bearer $WEXA_API_KEY"
```

```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 …" }
  ]
}
```

This is the fastest way to tell "my credential is wrong" from "my payload is wrong": the first
failing stage answers it.

## What to retry

Only two failures are worth retrying without changing anything: **`429`** and **an unlabelled
`5xx`**. Everything else will refuse identically on the second attempt.

### Honour `Retry-After`

On a `429` the gateway sends the quota window's real remaining seconds. Returning before that
window resets only spends another `429`. Add a small random offset so a fleet of clients told to
wait the same number of seconds does not return in lockstep.

Where there is no header, back off with full jitter — a uniform draw from `[0, 2^attempt)` seconds —
capped at 60, which is the length of the quota window. The default ceiling is not arbitrary: waiting
longer than a window cannot help, and waiting less than the window cannot succeed.

### The 502 distinction

A `502` carrying `"error":"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 `"error":"unconfigured"` or `"error":"unavailable"` is a **deployment
fault** — a service that is not wired up at all. Retrying will never fix it. Tell an operator. Both
SDKs encode exactly this distinction by mapping the second group to a class that is excluded from
the retry set despite its 5xx status.

## Quota

The defaults are 120 requests per project and 600 per organization, in a sliding 60-second window.
`POST /v1/docs` reports your live headroom without costing you a call's worth of guesswork:

```json
"quota": { "org_window_max": 600, "org_window_used": 12, "project_window_max": 120, "project_window_used": 12 }
```

A value of `0` for a maximum means unlimited. See
[quota and credits](/docs/concepts/quota-and-credits) for how the windows are configured.

## Related

  <Card title="REST overview" href="/docs/surfaces/rest/overview">
    The tool-call convention and the uniform envelope.
  </Card>
  <Card title="SDK error handling" href="/docs/surfaces/typescript/error-handling">
    The same failures as a typed hierarchy with retryability decided.
  </Card>
  <Card title="Lifecycle and audit" href="/docs/concepts/lifecycle-and-audit">
    What the stages are and what is recorded about each.
  </Card>
  <Card title="Quota and credits" href="/docs/concepts/quota-and-credits">
    The windows behind a 429.
  </Card>
