> ## Documentation Index
> Fetch the complete documentation index at: https://stockcontext.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors and retries

> Distinguish a refused value from a request error, retry only what is transient, and honor Retry-After.

There are two very different "something is missing" situations, and you handle them in opposite ways.

* A **refused value** is a successful response that contains `{ "unavailable": "..." }` in place of a value. It is a product fact, not an error — never retry it.
* A **request error** is a non-2xx HTTP status: bad key, over the limit, or a malformed body. Some are retryable, some are not.

## Refusals are not errors

When a field cannot be proven, it comes back as a refusal envelope inside an otherwise successful `200`:

```json theme={null}
{ "unavailable": "not_reported", "recovery": { "action": "use_full_document" } }
```

Branch on `unavailable`. Do not substitute `0` or `null`, and do not retry — the value will not appear on a second call. `recovery.action` tells an agent what to do instead. See [Response model](/docs/reference/response-model).

## Request errors

These are non-2xx statuses. Retry the transient ones; fix the cause of the rest.

| HTTP | Code                                    | Retry?     | What to do                                                                                                               |
| ---- | --------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ |
| 401  | `missing_api_key`                       | No         | Add the `X-API-Key` header.                                                                                              |
| 401  | `invalid_api_key`                       | No         | The key is unknown or revoked — fix or recreate it.                                                                      |
| 404  | `no_match` or endpoint-specific refusal | No         | The requested symbol or inventory item was not found; search or change the request.                                      |
| 413  | `request_too_large`                     | No         | The body exceeded the request cap or nesting guard; shrink the JSON.                                                     |
| 422  | validation error / `invalid_request`    | No         | Fix the request body. Schema 1 returns `detail`; schema 2 returns `data.unavailable: "invalid_request"` with `errors[]`. |
| 429  | `rate_limit_exceeded`                   | Yes        | You exceeded your plan's per-minute rate. Back off and retry.                                                            |
| 429  | `quota_exhausted`                       | Yes, later | Daily (Free) or monthly (Starter/Builder) quota is used up. Retry only after the window resets.                          |
| 5xx  | —                                       | Yes        | Transient server/upstream issue. Retry with backoff.                                                                     |

HTTP errors are never `{ "error": ... }`. Schema 2 validation errors use `{ "data": { "unavailable": "invalid_request", "errors": [...] }, "meta": { "schema_version": "2" } }`. Legacy schema-1 requests can still return the frozen flat refusal shape.

<Note>
  `rate_limit_exceeded` (per-minute) clears in seconds; `quota_exhausted` (daily or monthly) clears only at the window boundary, which can be hours or days away. If the response carries a `Retry-After` header, honor it. Authenticated tool responses also carry `X-Credits-Remaining` when Unkey reports remaining credits, so batch jobs can watch their budget directly.
</Note>

## A retry loop

Retry only on `429` and `5xx`, prefer a `Retry-After` header when present, cap the attempts, and cap the total wait so a quota exhaustion fails fast instead of sleeping for hours.

<Tabs>
  <Tab title="Python">
    ```python title="retry.py" theme={null}
    import asyncio
    import httpx

    RETRYABLE = {429, 500, 502, 503, 504}


    async def call_with_retry(
        client: httpx.AsyncClient,
        tool: str,
        body: dict,
        api_key: str,
        max_attempts: int = 3,
        max_total_wait: float = 90.0,
    ) -> dict:
        waited = 0.0
        for attempt in range(1, max_attempts + 1):
            res = await client.post(
                f"https://api.stockcontext.com/v1/tools/{tool}",
                json=body,
                headers={"X-API-Key": api_key},
            )
            if res.status_code == 200:
                return res.json()
            if res.status_code not in RETRYABLE or attempt == max_attempts:
                res.raise_for_status()

            retry_after = res.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else min(2 ** attempt, 8)
            if waited + delay > max_total_wait:
                raise RuntimeError("wait budget exhausted (likely quota_exhausted)")
            waited += delay
            await asyncio.sleep(delay)

        raise RuntimeError("retry loop exhausted")
    ```
  </Tab>

  <Tab title="TypeScript">
    ```ts title="retry.ts" theme={null}
    const RETRYABLE = new Set([429, 500, 502, 503, 504]);
    const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

    export async function callWithRetry(
      tool: string,
      body: unknown,
      apiKey: string,
      maxAttempts = 3,
      maxTotalWaitMs = 90_000,
    ) {
      let waited = 0;
      for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        const res = await fetch(`https://api.stockcontext.com/v1/tools/${tool}`, {
          method: "POST",
          headers: { "X-API-Key": apiKey, "Content-Type": "application/json" },
          body: JSON.stringify(body),
        });
        if (res.status === 200) return res.json();
        if (res.status === 404 || res.status === 413 || !RETRYABLE.has(res.status) || attempt === maxAttempts) {
          throw new Error(`HTTP ${res.status}`);
        }

        const retryAfter = res.headers.get("Retry-After");
        const delayMs = retryAfter ? Number(retryAfter) * 1000 : Math.min(2 ** attempt, 8) * 1000;
        if (waited + delayMs > maxTotalWaitMs) {
          throw new Error("wait budget exhausted (likely quota_exhausted)");
        }
        waited += delayMs;
        await sleep(delayMs);
      }
      throw new Error("retry loop exhausted");
    }
    ```
  </Tab>
</Tabs>

The wait budget is also your quota guard: when a `429` is a quota exhaustion, the reset can be hours away, so the loop refuses to sleep that long and surfaces it instead. Treat that as "stop and report," not an error to swallow. See [Plans and limits](/docs/reference/plans-and-limits).

## Where this goes wrong

* Treating a refused value (`unavailable` is present) as an error and retrying it. It is a stable `200`.
* Retrying `invalid_api_key` with the same revoked key, or retrying a `422` without fixing the body.
* Looping on `quota_exhausted` instead of waiting for the window to reset.
* Substituting `0` for a refused number, which silently ships a wrong value.

<Card title="Production patterns" icon="activity" href="/docs/guides/production-patterns">
  Drop this loop into a cached, key-safe client.
</Card>
