# Error Handling

Every non-2xx response from the Not AI Public REST API uses the same JSON envelope and the same enumerated code list. Treat the HTTP status as the coarse signal and the `error.code` string as the stable identifier you write logic against.

## Error envelope

```json
{
  "error": {
    "code": "INVALID_API_KEY",
    "message": "The presented API key is missing, malformed, or unknown.",
    "details": {
      "field": "..."
    }
  }
}
```

- `code` is a stable identifier from the enumerated list below. Codes never change meaning inside a major version.
- `message` is a human-readable sentence. Treat it as opaque. Copy changes are not breaking.
- `details` is an optional object with field-level context. Validation errors populate it with the offending field name; most other errors omit it.

Health (`GET /health`) and the OpenAPI document (`GET /openapi/v3.json`) are the only routes that never return this envelope. They have no error responses defined in v1.

## HTTP status codes

| Status | Meaning |
|--------|---------|
| `200` | Success. Body is the response envelope. |
| `204` | Success with no body. Used where the operation is intentionally empty. |
| `400` | The request was malformed or failed validation. `details` will name the offending field. |
| `401` | The request was unauthenticated. The key was missing, malformed, unknown, or for the wrong region. |
| `403` | The key is valid but is not permitted to access the requested resource (for example, a session that belongs to another integration). |
| `404` | The requested resource does not exist. |
| `429` | A per-minute rate-limit window was exhausted. A `Retry-After` header is included when the edge can compute one; if absent, back off with exponential delay. See [Rate Limits](/developers/rate-limits). |
| `5xx` | The API failed to serve the request. Retryable for idempotent reads. |

## Stable error codes

These codes are stamped on the v1 contract. New codes may be added in v1 (additive change); existing codes will never be removed or repurposed.

| Code | Typical status | When you see it |
|------|----------------|-----------------|
| `INVALID_API_KEY` | 401 | The presented key was missing, malformed, unknown, or for the wrong region. Identical envelope for all four cases by design. |
| `MISSING_API_KEY` | 401 | Reserved. The shipped middleware collapses all auth failures to `INVALID_API_KEY`; treat this code identically if you ever observe it. |
| `INVALID_REQUEST` | 400 | A query string or path parameter failed validation. `details` names the offending field. |
| `NOT_FOUND` | 404 | The path resolved to a resource that does not exist or is not visible to this integration. |
| `FORBIDDEN` | 403 | The key resolved successfully but cannot access the resource. |
| `RATE_LIMITED` | 429 | A per-minute rate-limit window was exhausted. `Authorization: Bearer` callers are isolated by key; `x-api-key` callers share a per-tier edge bucket and can be throttled by unrelated same-tier traffic. Honor `Retry-After` when present; otherwise back off exponentially. |
| `UNSUPPORTED_OPERATION` | 400 | The endpoint is documented but the specific combination of arguments is not yet supported. Adjust the request. |
| `SERVICE_UNAVAILABLE` | 503 | A downstream dependency is unavailable. Retry with backoff. |
| `INTERNAL_ERROR` | 500 | An unhandled exception was caught by the global error middleware. Retry with backoff and report if it persists. |

## Examples

400, bad query string:

```json
{
  "error": {
    "code": "INVALID_REQUEST",
    "message": "pageSize must be between 1 and 100.",
    "details": { "field": "pageSize" }
  }
}
```

401, any auth failure:

```json
{
  "error": {
    "code": "INVALID_API_KEY",
    "message": "The presented API key is missing, malformed, or unknown."
  }
}
```

404, unknown session id:

```json
{
  "error": {
    "code": "NOT_FOUND",
    "message": "No session with that id is visible to this integration."
  }
}
```

500, unhandled server-side fault:

```json
{
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "An unexpected error occurred."
  }
}
```

## Retry semantics

The API performs no automatic server-side retries on your behalf. Clients should implement their own retry policy with the following rules:

- **2xx**. Never retry.
- **400, 401, 403, 404**. Never retry. Fix the request and try again.
- **429**. Honor `Retry-After` when the response includes it; otherwise back off with exponential delay and jitter. Do not retry sooner than the floor of either path.
- **5xx (500, 502, 503, 504)**. Retry the request with exponential backoff and jitter. All v1 endpoints are `GET` and idempotent, so a retry is always safe.

A reasonable default policy for a server-to-server consumer:

```python
import os
import time
import random
import requests

BASE_URL = "https://api.isnotai.com"
HEADERS = {"Authorization": f"Bearer {os.environ['ISNOTAI_API_KEY']}"}

def get_with_retry(path, params=None, max_attempts=5):
    for attempt in range(max_attempts):
        response = requests.get(f"{BASE_URL}{path}", headers=HEADERS, params=params, timeout=10)

        if response.status_code < 400:
            return response.json()

        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            if retry_after and retry_after.isdigit():
                time.sleep(int(retry_after))
            else:
                time.sleep((2 ** attempt) + random.random())
            continue

        if 500 <= response.status_code < 600:
            backoff = (2 ** attempt) + random.random()
            time.sleep(backoff)
            continue

        response.raise_for_status()

    raise RuntimeError(f"giving up on {path} after {max_attempts} attempts")
```

Log the full error envelope (`code`, `message`, and `details`) when a retry policy gives up. That is what Not AI support will need to triage.
