

## Error envelope [#error-envelope]

All error responses share the same shape:

```json title="error envelope"
{
  "error": {
    "code": "SNAKE_CASE_CODE",
    "message": "Human-readable explanation."
  }
}
```

Unlike successful responses, error bodies are **not** wrapped in `meta` — they're just the
`error` object. Some errors include additional structured fields alongside `code` and
`message` — `NOT_SUPPORTED` carries `league` and `capability` fields to aid feature
detection. Most do not.

<Callout type="info" title="X-Request-Id">
  Every response — success **and** error — returns a `request_id` (v4 UUID) in the
  `X-Request-Id` response header (successful responses also echo it in `meta.request_id`).
  Capture it in your logs and include it in support tickets; it's the fastest way for us to
  pull the full trace. See the [Production checklist](/docs/guides/production-checklist) for the wiring.
</Callout>

## Status codes [#status-codes]

| Status                      | Meaning                                                                                         | Retry?                                                  |
| --------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `400 Bad Request`           | Invalid parameters (bad date format, unknown stat key, bad stage selector)                      | Fix the request                                         |
| `401 Unauthorized`          | Missing or invalid API key                                                                      | Check your key — see [Auth](/docs/getting-started/auth) |
| `403 Forbidden`             | Free-tier key calling a paid endpoint (`TIER_REQUIRES_PAID`)                                    | Upgrade — don't retry                                   |
| `404 Not Found`             | Resource doesn't exist (person, team, contest, competition)                                     | Don't retry                                             |
| `429 Too Many Requests`     | Rate limit (`RATE_LIMIT_EXCEEDED`) or monthly quota (`QUOTA_EXCEEDED`) — branch on `error.code` | See below                                               |
| `500 Internal Server Error` | Something broke on our side (`INTERNAL_ERROR`, `CACHE_ERROR`)                                   | Retry once, then page support with `request_id`         |
| `501 Not Implemented`       | Capability not supported for this competition (`NOT_SUPPORTED`)                                 | Don't retry — check [capabilities](/docs/api)           |
| `502 Bad Gateway`           | An upstream data provider failed (`PROVIDER_ERROR`)                                             | Retry with exponential back-off                         |
| `503 Service Unavailable`   | All providers failed, DB pool unavailable, or auth backend down                                 | Retry with exponential back-off                         |

For the full machine-readable code catalog (`UNAUTHORIZED`, `QUOTA_EXCEEDED`, `RATE_LIMIT_EXCEEDED`,
`NOT_SUPPORTED`, `UNKNOWN_STAT`, …), see [Error codes](/docs/getting-started/error-codes).

## Retry guidance [#retry-guidance]

* **429** — read `error.code` first. `RATE_LIMIT_EXCEEDED` means the per-second cap: back
  off a second or two and continue. `QUOTA_EXCEEDED` means the monthly unit cap: retrying
  won't help until the billing period resets or you upgrade (`X-RateLimit-Reset` carries
  the period-end timestamp on quota responses).
* **502 / 503** — use exponential back-off starting at 1 s, cap at 30 s, give up after 3–5
  attempts.
* **500** — retry once; if it persists, page support with the `request_id`.

Everything else (400, 401, 403, 404, 501) is deterministic; retrying without changing the
request, key, or plan will produce the same result.
