Skip to main content
Every StockContext response is either { "data": ... } or { "error": { "code", "message", "retryable" } }. Read error.retryable and do exactly what it says: retry when it is true, surface the fix when it is false. Do not infer recovery from the HTTP status alone, and never retry an unsupported data state: that is a 200 with real data, not an error.

Retry policy

This table is the whole decision. retryable comes straight off the envelope; the HTTP status is for logging, not branching.
CodeHTTPRetry?What to do
UNAUTHORIZED401NoFix the X-API-Key header; recreate the key if it was revoked.
PLAN_UPGRADE_REQUIRED403NoThe route needs Starter or Builder; stay on Free routes or upgrade.
SYMBOL_INVALID400NoFix the ticker format (^[A-Z0-9-]{1,8}$, e.g. BRK-B).
SYMBOL_UNKNOWN404NoResolve the name with /v2/search first; the symbol is outside the universe.
NOT_FOUND404NoThe path is not a StockContext route; fix the URL or base path. Distinct from SYMBOL_UNKNOWN; carries no rate-limit headers (404s before key verification).
PARAM_INVALID400NoFix params. The message names the offending parameter and, for enum/range errors, the allowed values.
RATE_LIMITED429YesWait the Retry-After seconds, then resume slower.
UPSTREAM_UNAVAILABLE503YesRetry with backoff; a cold SEC path may need a loading state.
AUTH_UPSTREAM_UNAVAILABLE503YesRetry shortly; sends Retry-After: 5.
PREREQUISITE_MISSING409NoA required cached prerequisite is missing; call the underlying endpoints first, then retry the request manually.
PARAM_INVALID names the offending parameter and lists allowed values, so you can branch on it: a bad range returns range '99z' is not a recognized window; allowed: 10y, 1m, 1y, 2y, 30y, 3m, 5y, 6m, max, ytd., and an unsupported fields value returns the param name plus the full allowed set. Log the request you sent too: the message tells you which param, but your own log tells you the value. The error codes reference carries the message shapes.

The 429 envelope

A rate-limited response pairs the retryable error body with headers that tell you exactly how long to wait. This is the live fixture:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1780669200
Retry-After: 21
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Rate limit exceeded. Please slow request rate and retry.",
    "retryable": true
  }
}
Retry-After is in seconds (21 here). Sleep at least that long before the next request for that key; sending sooner earns another 429.

A retry loop that obeys its own rules

The loop has to do four things the table implies: retry only on retryable: true, prefer the Retry-After header over a guessed backoff, cap total attempts so a persistent outage fails fast, and cap total wait so you never sleep for hours. The wait budget must cover a full minute window: a minute-bucket 429 can send Retry-After of up to 60 seconds, so a 30-second budget would give up on a limit that was about to clear. Below, three attempts and a 90-second ceiling.
retry.py
import asyncio
import httpx

async def get_with_retry(
    client: httpx.AsyncClient,
    url: str,
    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.get(url, headers={"X-API-Key": api_key})
        body = res.json()

        if "data" in body:
            return body["data"]

        err = body["error"]
        retryable = err["retryable"]
        if not retryable or attempt == max_attempts:
            raise RuntimeError(f"[{err['code']}] {err['message']}")

        # Prefer the server's Retry-After; fall back to exponential backoff.
        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(f"[{err['code']}] wait budget exhausted")
        waited += delay
        await asyncio.sleep(delay)

    raise RuntimeError("retry loop exhausted")
The budget guard is also your quota protection. When a 429 comes from the plan quota rather than the minute bucket (Free’s daily 100, paid monthly quotas), Retry-After is the time to the window reset and can be hours (a Free daily 429 sends roughly the seconds until midnight UTC). The loop above correctly refuses to sleep that long and fails fast with wait budget exhausted; treat that as “stop the job and surface it”, not as an error to swallow. Plans and limits covers how the two windows show up in the headers. The same loop works behind an MCP client: a tool failure renders as [CODE] message (retryable: true|false), so parse the retryable flag out of that string and branch identically. PREREQUISITE_MISSING is non-retryable; call the underlying endpoints it depends on first instead of looping.

Where this goes wrong

  • Parsing a success as a raw object instead of reaching into { data }.
  • Retrying UNAUTHORIZED with the same revoked key, or treating PLAN_UPGRADE_REQUIRED as an outage.
  • Retrying freshness: "unsupported"; it is a stable 200, not a transient failure.
  • Ignoring Retry-After and hammering the same key into a second 429.
  • Retrying forever. Without an attempt cap, a real upstream outage turns one slow request into a hung worker.

Production patterns

Drop this retry loop into a cached, key-safe client.