# Errors & retries

> Every runstate error code with its HTTP status, SDK exception class and typical cause, plus exactly what the SDKs retry automatically and what your code must handle.

Source: https://docs.getrunstate.com/errors/

Every error from the API uses one envelope:

```json
{
  "error": {
    "code": "INSUFFICIENT_BUDGET",
    "message": "reservation exceeds available budget",
    "requestId": "…"
  }
}
```

Codes and their HTTP statuses are a versioned contract. Branch on `code` (and the SDK exception class), never on `message`. Include `requestId` when you report a problem.

## Error codes

The SDK classes are the same in TypeScript and Python. Both SDKs are tested against one shared mapping fixture.

| Code | HTTP | SDK class | Typical cause | What to do |
| --- | --- | --- | --- | --- |
| `VALIDATION_FAILED` | 400 | `RunstateError` | Malformed body or parameters, missing `Idempotency-Key` on the raw API, payload over the size limit, settle larger than the reservation. | Fix the request. |
| `UNAUTHENTICATED` | 401 | `AuthenticationError` | Missing or invalid API key. | Check `RUNSTATE_API_KEY`. |
| `FORBIDDEN` | 403 | `AuthenticationError` | The key lacks the permission for this call, or isn't allowed for this space. | See [key permissions](https://docs.getrunstate.com/configuration/#api-keys-and-permissions). |
| `ENTITLEMENT_EXCEEDED` | 403 | `AuthenticationError` | A plan ceiling was hit, such as active runs, active tasks or keys, or a space limit set above the plan. | Finish or cancel work, or change plan. |
| `SPACE_SUSPENDED` | 403 | `AuthenticationError` | The space was suspended by an owner. New work is refused; releasing, renewing, completing and retrying work already held still works. | Resume the space in the console. |
| `NOT_FOUND` | 404 | `NotFoundError` | Unknown id or name, or an id from another organization. | Check the id; create the resource with `ensure` first. |
| `CONFLICT` | 409 | `ConflictError` | Generic state conflict, such as cancelling a finished task or run, accepting a member that hasn't succeeded, or `ensure` with different settings. | Read the current state and decide. |
| `CLAIM_HELD` | 409 | `ConflictError` | Another holder owns the key. | Wait (`wait: true`), use `tryAcquire()`, or skip. |
| `IDEMPOTENCY_CONFLICT` | 409 | `ConflictError` | Same task key or `workKey` with a different input, same usage key with a different amount, or an `Idempotency-Key` reused with a different request. | Use a new key for different work. |
| `SCOPE_CANCELLED` | 409 | `ScopeCancelledError` | The run (or an ancestor) was cancelled or completed. | Stop taking work for this run. |
| `DEADLINE_EXCEEDED` | 409 | `ConflictError` | The run's deadline has passed. | Stop taking work for this run. |
| `STALE_CLAIM` | 409 | `LeaseLostError` | The lease behind this token was lost; someone else may own the work now. | Stop; don't retry with the old token. |
| `CAPACITY_UNAVAILABLE` | 409 | `ConflictError` | The concurrency pool has no free units. | Wait (`wait: true`, the pool default) or back off. |
| `BACKLOG_FULL` | 409 | `ConflictError` | The space's queued-message limit is reached. | Consume faster or raise `maxBacklog`, see [Limits](https://docs.getrunstate.com/limits/#per-space-limits). |
| `INSUFFICIENT_BUDGET` | 409 | `ConflictError` | The budget can't cover the reservation. | Don't start the work. |
| `BARRIER_RELEASED` | 409 | `ConflictError` | Arrived at a barrier that already released. | Treat as done. |
| `BARRIER_CANCELLED` | 409 | `ConflictError` | The barrier was cancelled. | Stop waiting. |
| `LEASE_RENEWALS_EXHAUSTED` | 409 | `LeaseLostError` | The server refused further renewals of this lease. | Stop; the lease is gone. |
| `RATE_LIMITED` | 429 | `RateLimitError` | The space's `requestsPerMinute` limit was exceeded. | Back off and retry, or raise the [space limit](https://docs.getrunstate.com/limits/#per-space-limits). |
| `SERVICE_RATE_LIMITED` | 429 | `RateLimitError` | Your organization used its plan's requests for this minute. Renew, release, retry, complete, cancel, detach and ack calls have a reserved share, so recovery keeps working. | Back off until the next minute. |
| `ALLOWANCE_EXHAUSTED` | 429 | `RateLimitError` | The shared quota's window is used up. | `take()` waits for you; `tryTake()` reports `exhausted`. |
| `ALLOWANCE_COOLDOWN` | 429 | `RateLimitError` | A cooldown is active on the shared quota. | Same as above. |
| `CONCURRENCY_LIMITED` | 429 | `RateLimitError` | Your organization is at its plan's "agents working at once" limit. | Claims and pools wait through it; elsewhere, back off. |
| `OPERATIONS_EXHAUSTED` | 429 | `RateLimitError` | The free plan's monthly operations are used up. New work is refused until next month; running work can finish. | Wait for the next month or change plan. |
| `UNAVAILABLE` | 503 | `UnavailableError` | The service or the network failed, including after the SDK's transport retries. | Retry later. |

Two classes never come from the server:

- `ConfigError` (`status` 0): the client was constructed without an API key or space id.
- `WaitTimeoutError` (`status` 0, code `WAIT_TIMEOUT`): a client-side wait ran out, in `result()`, `wait()`, `acquire({ wait: true })` or `take()`. The work on the server is unaffected.

`CursorExpiredError` exists in both SDKs for a future pagination code. The server does not emit `CURSOR_EXPIRED` today.

## What the SDKs retry for you

| Situation | Retried automatically? |
| --- | --- |
| Network error or per-attempt timeout | Yes: `retries` extra attempts (default 2) with 25–100 ms jitter, reusing the same `Idempotency-Key`. Then `UnavailableError`. |
| Any error response from the API | **No.** It's thrown as a typed error immediately. |
| `CLAIM_HELD` | Only inside `claim.acquire({ wait: true })` / `claim.run(fn, { wait: true })`: every ~300 ms until `timeoutMs`. |
| `CAPACITY_UNAVAILABLE` | Inside `pool.acquire()` / `pool.run()` while `wait` is true (the default): every ~300 ms until `timeoutMs`. |
| `CONCURRENCY_LIMITED` | Inside waiting claim and pool acquisition: every ~1 s. Not for quota takes or raw calls. |
| `ALLOWANCE_EXHAUSTED`, `ALLOWANCE_COOLDOWN` | Inside `quota.take()` while `wait` is true: a durable server-side waiter, polled every ~250 ms. |
| Lease renewal failure | **Never.** The lease is marked lost and renewal stops. |
| `RATE_LIMITED`, `SERVICE_RATE_LIMITED`, `OPERATIONS_EXHAUSTED` | No. |
| Errors inside `consume()` while receiving | Authentication, configuration and not-found errors are rethrown; `SCOPE_CANCELLED` ends the loop; anything else is retried after `pollMs`. |

## Handling errors

**TypeScript**

```ts
import { ConflictError, LeaseLostError, RateLimitError, RunstateError, WaitTimeoutError } from 'runstate-sdk';

async function reserveForCall(amount: string) {
  try {
    return await run.budget('research-usd').reserve({ amount });
  } catch (err) {
    if (err instanceof ConflictError && err.code === 'INSUFFICIENT_BUDGET') return null;
    if (err instanceof RateLimitError) {
      await new Promise((resolve) => setTimeout(resolve, err.retryAfterMs ?? 1000));
      return reserveForCall(amount);
    }
    if (err instanceof LeaseLostError || err instanceof WaitTimeoutError) throw err;
    if (err instanceof RunstateError) {
      console.error(`runstate ${err.code} (${err.status}), request ${err.requestId}`);
    }
    throw err;
  }
}
```

**Python**

```python
import asyncio

from runstate import ConflictError, LeaseLostError, RateLimitError, RunstateError, WaitTimeoutError

async def reserve_for_call(amount: str):
    try:
        return await run.budget("research-usd").reserve(amount=amount)
    except ConflictError as err:
        if err.code == "INSUFFICIENT_BUDGET":
            return None
        raise
    except RateLimitError as err:
        await asyncio.sleep((err.retry_after_ms or 1000) / 1000)
        return await reserve_for_call(amount)
    except (LeaseLostError, WaitTimeoutError):
        raise
    except RunstateError as err:
        print(f"runstate {err.code} ({err.status}), request {err.request_id}")
        raise
```

## Idempotency and retrying safely

- **SDK calls.** Each call sends a fresh `Idempotency-Key`, reused only across that call's own transport retries. If you retry a whole call yourself, it is a new request. Make it safe with the domain keys: task `key`, message `workKey`, and settle/void `usageKey` all deduplicate.
- **Raw HTTP.** Send an `Idempotency-Key` on every mutation (required on most). Reusing a key with the same body within 24 hours replays the stored response; with a different body it returns `IDEMPOTENCY_CONFLICT`.
- **After a lost response**, don't repeat a completion. Read the state instead (`ticket.status()`): a repeated completion after the first one landed returns `STALE_CLAIM`.
