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.
Every error from the API uses one envelope:
{ "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
Section titled “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. |
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. |
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. |
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(status0): the client was constructed without an API key or space id.WaitTimeoutError(status0, codeWAIT_TIMEOUT): a client-side wait ran out, inresult(),wait(),acquire({ wait: true })ortake(). 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
Section titled “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
Section titled “Handling errors”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; }}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}") raiseIdempotency and retrying safely
Section titled “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: taskkey, messageworkKey, and settle/voidusageKeyall deduplicate. - Raw HTTP. Send an
Idempotency-Keyon every mutation (required on most). Reusing a key with the same body within 24 hours replays the stored response; with a different body it returnsIDEMPOTENCY_CONFLICT. - After a lost response, don’t repeat a completion. Read the state instead (
ticket.status()): a repeated completion after the first one landed returnsSTALE_CLAIM.