---
title: Errors and retries
description: Which refusals you handle, which you surface, and which you must never retry.
---

One rule carries most of the weight:

:::danger Never retry a 403
A `403` is the server's **answer**, not a transport failure. The pipeline ran,
every stage was evaluated, and the result was a refusal. Calling again produces
the identical refusal, burns your rate limit, and fills the audit trail with
noise that makes the real decision harder to find.

Retry `429` and `503`. Retry transport failures. Never retry a decision.
:::

## The error envelope

```json
{
  "error": {
    "code": "AUTHORITY_EXCEEDED",
    "message": "Requested action exceeds delegated authority.",
    "request_id": "req_01JAV7YQ4T2K9M3E8H6R5N1XZ0",
    "details": { "capability": "refund.create", "ceiling": 50000, "requested": 120000 }
  }
}
```

Branch on `code`, never on `message`. Messages are written for humans and get
refined; codes are a contract. `request_id` is also the `x-request-id` header,
and it is the fastest way to find the matching action and audit records.

The whole code table is served at `GET /v1/meta/error-codes` with no
authentication, so a client can validate its own handling against the deployment
it is actually talking to.

## What to do, by class

| Status | Meaning | Retry | Handle by |
| --- | --- | --- | --- |
| `400` | Malformed or inconsistent request | no | Fixing the request |
| `401` | No usable identity | once, after re-authenticating | Then failing loudly |
| `403` | Identity established, request refused | **never** | Reporting the reason |
| `404` | No such resource in this tenant | no | Fixing the id |
| `409` | Raced, or idempotency key reused | no | Reading current state first |
| `422` | Well-formed, not satisfiable | no | Changing the request |
| `429` | Rate limited | yes, with backoff | Honouring `Retry-After` |
| `500` | Internal error | once | Reporting with `request_id` |
| `502` | Downstream rejected it | depends on your downstream | Treating as a downstream failure |
| `503` | A dependency is unavailable | yes, with backoff | Accepting that you are blocked |
| `504` | Downstream timed out | **only if idempotent** | Checking state first |

## The refusals you will actually see

### `ACTION_BLOCKED`

Policy said no. `explanation.matched_rule` names the rule and
`explanation.matched_rules` carries the per-condition trace.

Report `reason` verbatim — it is operator-authored text from the policy rule and
it is meant to be read by whoever is affected. Do not retry.

### `REQUIRE_APPROVAL` → `APPROVAL_REQUIRED`

A human must decide. Not an error in the usual sense — an instruction to wait.

Use `actWithApproval`, or persist the `approvalId` and resume. Honour
`pollAfterMs`. See [Handle approvals](/build/handle-approvals).

### `AUTHORITY_EXCEEDED`

The delegation chain resolved and the request is outside it. `details` carry the
capability, the ceiling and what was requested.

```bash
curl -s https://veefore.example.com/v1/agents/agt_01ABC/authority \
  -H "Authorization: Bearer $VEEFORE_API_KEY"
```

Read the **resolved** authority, not the individual grant — narrowing anywhere
in the chain narrows everything below it.

### `CAPABILITY_NOT_GRANTED` vs `TOOL_NOT_AUTHORIZED`

Two halves of the same pair, kept distinct so you can tell which is missing.

- `CAPABILITY_NOT_GRANTED` — the agent does not hold the capability. Grant it with `agent:manage_capabilities`.
- `TOOL_NOT_AUTHORIZED` — it holds the capability but is not authorized for this specific tool. `POST /v1/tools/{toolId}/agents`.

### `AGENT_AUTHENTICATION_FAILED`

Almost always one of:

1. **Audience mismatch.** The most common by a wide margin. The gateway compares `aud` as a string, so `127.0.0.1` and `localhost` are different values. Fetch the real one from `/.well-known/veefore-platform-key`.
2. **Wrong canonicalisation.** Only if you are signing yourself. See [Sign your own assertion](/build/sign-your-own-assertion).
3. **Clock drift.** Beyond the acceptance window. Separately reported as `TIMESTAMP_OUT_OF_WINDOW`.

### `IDENTITY_GENERATION_STALE`

The key was rotated. Re-read `VEEFORE_AGENT_PRIVATE_KEY` and
`VEEFORE_AGENT_KEY_ID` from your configuration source and reconnect. A
long-running process that caches configuration at startup needs a path for this.

### `AGENT_IDENTITY_REVOKED`

Terminal. The identity is gone and there is no recovery. Stop, alert a human. Do
not retry, and do not build a fallback that works around it.

### `NONCE_REPLAYED`

An assertion was reused. Generate a fresh nonce for every assertion. If you are
seeing this from an SDK, you are probably caching an assertion object.

### `ENVIRONMENT_PAUSED` / `AGENT_NOT_OPERATIONAL` / `AGENT_ISOLATED`

Someone deliberately stopped this. Back off substantially and surface it — a
paused environment is an operator decision, and hammering it does nothing except
make the logs harder to read during whatever they are dealing with.

### `TOOL_DISABLED`

The tool was turned off for everyone. Distinct from a policy `BLOCK` so your
logs say which happened.

### `*_SERVICE_UNAVAILABLE`

`IDENTITY_SERVICE_UNAVAILABLE`, `POLICY_SERVICE_UNAVAILABLE`,
`APPROVAL_SERVICE_UNAVAILABLE`, `CREDENTIAL_SERVICE_UNAVAILABLE`,
`AUDIT_SERVICE_UNAVAILABLE`.

:::security These are fail-closed working
A dependency enforcement relies on is down, so protected actions are refused
rather than waved through. There is no mode in which an error becomes an allow.

Retry with backoff. Do not build a bypass — a code path that proceeds when
Veefore cannot evaluate is a code path an attacker will find and trigger
deliberately.
:::

### `EXECUTION_FAILED` and `EXECUTION_TIMEOUT`

Veefore **permitted** the action and the downstream rejected it or did not
respond. The decision was allowed; the execution was not.

`EXECUTION_TIMEOUT` is the dangerous one: whether the downstream applied the
side effect is unknown. Which is precisely why mutating tools should be
idempotent and why you should send an `Idempotency-Key`. See
[Idempotency](/build/idempotency).

### `VELOCITY_LIMIT_EXCEEDED`

The agent is acting faster than policy permits. Slow down. A retry loop here
makes it worse by definition.

## Retry, correctly

```ts
const RETRYABLE = new Set([429, 503]);

async function withRetry<T>(call: () => Promise<T>, attempts = 4): Promise<T> {
  for (let attempt = 0; attempt < attempts; attempt += 1) {
    try {
      return await call();
    } catch (error) {
      const status = (error as { status?: number }).status;
      const transport = status === undefined;
      if (!transport && !RETRYABLE.has(status)) throw error; // 403 lands here

      if (attempt === attempts - 1) throw error;
      // Exponential with full jitter, so a fleet of agents does not
      // synchronise its retries into a thundering herd.
      const base = Math.min(1000 * 2 ** attempt, 15_000);
      await new Promise((resolve) => setTimeout(resolve, Math.random() * base));
    }
  }
  throw new Error('unreachable');
}
```

The SDKs already do this. `maxRetries` applies to transport errors only, and a
decision is never retried.

## Report refusals usefully

```ts
catch (error) {
  if (error instanceof VeeforeBlockedError) {
    logger.warn({
      event: 'veefore.refused',
      code: 'ACTION_BLOCKED',
      requestId: error.decision.requestId,
      actionId: error.decision.actionId,
      rule: error.decision.explanation.matched_rule,
    }, error.message);

    return { status: 'refused', reason: error.message };
  }
}
```

Log the `requestId` and the `actionId`. Those two turn "the agent was blocked"
into a one-command investigation:

```bash
veefore logs --limit 50
```

And pass `reason` through to whoever is waiting. It is written to be read.

## Related

:::cards

:::card Error codes | /reference/errors
Every code with its status and message. Generated.
:::

:::card Fail closed | /secure/fail-closed
Why a dependency outage refuses.
:::

:::card Idempotency | /build/idempotency
Safe retries on mutating requests.
:::

:::card Troubleshooting | /operate/troubleshooting
Diagnosing from the operator side.
:::

:::
