# Errors & rate limits

## Error format (REST)

Every `/api/v1` error is an [RFC 9457 / RFC 7807](https://www.rfc-editor.org/rfc/rfc9457)
Problem Details document with `Content-Type: application/problem+json`:

```json
{
  "type": "https://qlim8.com/errors/auth.scope_required",
  "title": "Insufficient scope",
  "status": 403,
  "code": "auth.scope_required",
  "detail": "Requires scope 'targets:write'. Call GET /v1/me to see your key's scopes.",
  "instance": "/api/v1/targets",
  "scope_required": "targets:write"
}
```

**`code` is the stable key to branch on** — `type` is derived from it, `title`/`detail` are
human-readable, and some errors add extra fields (e.g. `scope_required`, `feature`,
`retry_after_seconds`, `issues`).

> A scope-denied error deliberately does **not** echo your granted scopes (it would leak a
> consultant grant). Call `GET /api/v1/me` to see what your key can do.

## Error format (MCP)

The MCP server splits errors in two:

- **Tool-execution errors are in-band:** a normal `tools/call` result with `isError: true` and
  a JSON body `{ code, title, detail }` using the same `code` values below, so the LLM can read
  and self-correct.
- **Protocol-level failures use JSON-RPC error codes:** `-32001` (auth / rate limiting),
  `-32603` (internal). Input-validation failures arrive in-band as `request.validation_failed`,
  **not** as `-32602`.

## Troubleshooting table

| HTTP | `code` | Cause | Fix |
|---|---|---|---|
| 401 | `auth.missing_token` | No / malformed `Authorization` header | Send `Authorization: Bearer qk_live_<key>` (or `qk_sandbox_<key>`). |
| 401 | `auth.invalid_token` | Key not found | Check the key value; it may be a typo or a deleted key. |
| 401 | `auth.key_revoked` | Key was revoked | Mint a new key. |
| 401 | `auth.key_expired` | Key past `expires_at` | Mint a new key (optionally without an expiry). |
| 401 | `auth.key_environment_mismatch` | Live key on a sandbox host (or vice-versa) | Use the matching host, or a key whose prefix matches the host. |
| 403 | `auth.ip_not_allowed` | Source IP not in the key's allowlist | Call from an allowed IP, or widen/remove the allowlist. |
| 403 | `auth.scope_required` | Key lacks the endpoint's scope | Mint a key with the `scope_required` scope; check `GET /api/v1/me`. |
| 403 | `subscription.feature_required` | Endpoint/tool needs a plan feature (e.g. `supplyChain`, `scenarioBuilder`) | Upgrade the plan / enable the `feature` named in the error. |
| 400 | `third_party.tenant_header_missing` | Consultant key without `X-Qlim8-Tenant` | Add `X-Qlim8-Tenant: <client-tenant-id>`. |
| 403 | `third_party.no_active_grant` | No active grant for the target tenant | Request/await a grant; list with `GET /api/v1/grants`. |
| 403 | `third_party.endpoint_only` | Endpoint is third-party-only (or tenant-only) for this key type | Use the correct key type. |
| 400 | `request.validation_failed` | Bad/missing parameter | Read the `issues` array and correct the request. |
| 400 | `pagination.invalid_cursor` | Cursor couldn't be decoded | Drop the `cursor` parameter and start over. |
| 404 | `resource.not_found` | The id doesn't exist or isn't visible to this key | Verify the id; list first. |
| 404 | `route.not_found` | No such `/api/v1` endpoint | Check the path/method. |
| 422 | `idempotency.key_mismatch` | `Idempotency-Key` reused with a different body | Use a fresh key (see [idempotency](./idempotency.md)). |
| 429 | `rate_limit.exceeded` | Per-API-key rate limit hit | Back off using `Retry-After`; spread load. |
| 429 | `rate_limit.ip_exceeded` | Pre-auth edge IP limit hit | Back off using `Retry-After`; reduce concurrency from this IP. |
| 500 | `server.internal_error` | Server-side error | Retry with backoff; contact support if it persists. |

## Rate limits

Two independent layers apply to `/api/v1` and `/api/mcp`:

1. **Edge IP limit (pre-auth).** A per-source-IP cap, default **300 req/min/IP** (configurable
   via the `V1_EDGE_RPM` env var), sits in front of everything and runs **before** your key is
   checked. Exceeding it returns `429 rate_limit.ip_exceeded` (REST) or JSON-RPC `-32001`
   (MCP), with a `Retry-After` header.
2. **Per-API-key limit (post-auth).** Buckets are keyed on the API key and split by method:
   - **Read** (`GET`/`HEAD`/`OPTIONS`, and read-annotated MCP tools): **600 req/min**
   - **Write** (everything else): **60 req/min**
   - **Sandbox** keys: **10× stricter** (60 read / 6 write).

   A consultant key serving many clients shares **one** budget per key.

Successful responses carry:

```
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 583
X-RateLimit-Reset: 1718000060      # unix seconds
```

A 429 carries `Retry-After: <seconds>` and a `retry_after_seconds` field in the body.

## Handling a 429

Respect `Retry-After`; don't hammer. A simple retry-with-backoff:

**curl** (bash)
```bash
KEY="qk_live_<your-key>"
url="https://api.qlim8.com/api/v1/emissions/summary?from=2024-01-01T00:00:00Z&to=2024-12-31T23:59:59Z"

while : ; do
  resp=$(curl -s -w "\n%{http_code}" -D /tmp/h "$url" -H "Authorization: Bearer $KEY")
  code=$(echo "$resp" | tail -n1)
  if [ "$code" = "429" ]; then
    retry=$(grep -i '^retry-after:' /tmp/h | tr -d '\r' | awk '{print $2}')
    echo "rate limited; sleeping ${retry:-5}s"
    sleep "${retry:-5}"
    continue
  fi
  echo "$resp" | sed '$d'
  break
done
```

**TypeScript (fetch)**
```ts
async function getWithBackoff(url: string, key: string, maxRetries = 5): Promise<Response> {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
    if (res.status !== 429 || attempt >= maxRetries) return res;
    const retryAfter = Number(res.headers.get("Retry-After")) || 2 ** attempt;
    await new Promise((r) => setTimeout(r, retryAfter * 1000));
  }
}

const res = await getWithBackoff(
  "https://api.qlim8.com/api/v1/emissions/summary?from=2024-01-01T00:00:00Z&to=2024-12-31T23:59:59Z",
  process.env.QLIM8_KEY!,
);
if (!res.ok) throw new Error(`[${res.status}] ${(await res.json()).code}`);
console.log(await res.json());
```

**Python (httpx)**
```python
import os, time, httpx

def get_with_backoff(url: str, key: str, max_retries: int = 5) -> httpx.Response:
    headers = {"Authorization": f"Bearer {key}"}
    with httpx.Client() as client:
        for attempt in range(max_retries + 1):
            resp = client.get(url, headers=headers)
            if resp.status_code != 429 or attempt == max_retries:
                return resp
            retry_after = int(resp.headers.get("Retry-After", 2 ** attempt))
            time.sleep(retry_after)
        return resp  # unreachable

resp = get_with_backoff(
    "https://api.qlim8.com/api/v1/emissions/summary"
    "?from=2024-01-01T00:00:00Z&to=2024-12-31T23:59:59Z",
    os.environ["QLIM8_KEY"],
)
resp.raise_for_status()
print(resp.json())
```
