# Error handling

> Every refusal is JSON with a `detail`, with two documented exceptions. Here is what each status means, what it looks like, and whether a retry can change it.

- **HTML:** https://offendersearch.app/docs/guides/sex-offender/error-handling
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## What you will build

One error handler for every Sex Offender API call: it parses the body, classifies the status into **fix the request**, **fix the account**, **wait and retry**, or **not an answer about the person**, and never turns a refusal into a clear result. The sandbox produces the `401`, `404`, `409`, `410`, `413` and `422` cases for real (never a `402`: nothing there is billed); `429` and `504` are shown in their production shapes because the sandbox does not simulate load.

## Before you start

- A test key. The sandbox returns real validation errors — identical to production — for malformed requests. Its own `429` (60 requests a minute, 2,000 a day per key, 120 a minute per IP) is a real protection of the sandbox and carries `Retry-After`.
- The envelope: `{"detail": …}` where `detail` is a **sentence** on most errors, a **list** of `{loc, msg, type, input}` when a field failed validation, or an **object with a `code`** on `429`, on the `504 incomplete_no_results`, on `403 location_quota_paused`, and on the clearance `404`/`409`.
- Two exceptions to the envelope: a personal (consumer) account calling a business route gets `403` with a bare `{"error": "use_background_check", "message": …, "path": …}`; and an unavailable evidence service answers `503` with `{"error": {"code": "unavailable", "message": …}}`.

> **The one thing people get wrong.** A non-2xx answer says nothing about the person. `401`, `402`, `422`, `429` and `504` must surface as **errors** in your workflow, never as "no records found". The only clear answer is `200` with `status: "complete"` and `counts.records: 0`.

## Step by step

1. **Parse the body defensively.** Read `detail` if present; otherwise `error` (string or object). Keep the raw body for logs.
2. **Branch on the status.** `4xx` other than `429` is yours to fix (request or account). `429` is wait-and-retry with `Retry-After`. `5xx` means the service did not answer; retry a Regular search, be careful with a live one.
3. **Map `422` to the field.** A list-shaped `detail` names `loc` (`["body", "query", "dob"]`); a sentence names the rule. Fix the request; do not retry unchanged.
4. **Never resubmit an async job on error.** If the `202` was received, poll it. Resubmitting creates a second billed job.

### The handler

**Python**
```python
import os, time, requests

BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")
H = {"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]}

class ApiError(Exception):
    def __init__(self, status, body, retry_after=None):
        super().__init__(f"HTTP {status}: {body}")
        self.status, self.body, self.retry_after = status, body, retry_after
    @property
    def detail(self):
        return self.body.get("detail", self.body.get("error")) if isinstance(self.body, dict) else self.body
    @property
    def code(self):
        d = self.detail
        return d.get("code") if isinstance(d, dict) else None

def call(method, path, json=None, attempts=4):
    for attempt in range(attempts):
        r = requests.request(method, BASE + path, headers=H, json=json, timeout=90)
        if r.status_code < 400:
            return r
        try:
            body = r.json()
        except ValueError:
            body = {"detail": r.text}
        err = ApiError(r.status_code, body, r.headers.get("Retry-After"))
        if r.status_code == 429:                                # not billed; wait what the server says
            time.sleep(int(err.retry_after or 2 ** attempt)); continue
        if r.status_code >= 500 and "live" not in (json or {}):  # Regular search: nothing ran, nothing billed
            if err.code == "incomplete_no_results":
                raise err                                       # a real answer: unfinished registries, no records
            time.sleep(2 ** attempt); continue
        raise err                                               # 4xx: fix the request or the account
    raise err

def classify(err):
    if err.status == 401: return "fix-key"          # wrong key, or a test key on the live host
    if err.status == 402: return "fix-billing"      # subscription / trial exhausted / trial query needs a name
    if err.status == 403: return "fix-account"      # consumer account, or location-record quota paused
    if err.status == 422: return "fix-request"
    if err.status in (404, 409, 410, 413): return "fix-reference"
    if err.status == 429: return "wait"
    if err.status == 504 and err.code == "incomplete_no_results": return "incomplete"
    return "service"

try:
    body = call("POST", "/v1/search", {"query": {"lastName": "Match"}, "match": "strick"}).json()
except ApiError as e:
    print(e.status, classify(e), e.detail)
# 422 fix-request Unknown match 'strick'. Allowed: balanced, broad, strict.
```

**JavaScript**
```javascript
const BASE = process.env.OFFENDERSEARCH_BASE ?? 'https://sandbox.offendersearch.app';
const H = { 'X-API-Key': process.env.OFFENDERSEARCH_KEY, 'Content-Type': 'application/json' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

class ApiError extends Error {
  constructor(status, body, retryAfter) {
    super('HTTP ' + status + ': ' + JSON.stringify(body));
    this.status = status; this.body = body; this.retryAfter = retryAfter;
  }
  get detail() { return this.body && typeof this.body === 'object' ? (this.body.detail ?? this.body.error) : this.body; }
  get code() { const d = this.detail; return d && typeof d === 'object' && !Array.isArray(d) ? d.code : undefined; }
}

async function call(method, path, json, attempts = 4) {
  let err;
  for (let attempt = 0; attempt < attempts; attempt++) {
    const res = await fetch(BASE + path, { method, headers: H, body: json ? JSON.stringify(json) : undefined });
    if (res.ok) return res;
    let body; try { body = await res.json(); } catch { body = { detail: await res.text() }; }
    err = new ApiError(res.status, body, res.headers.get('Retry-After'));
    if (res.status === 429) { await sleep(1000 * (Number(err.retryAfter) || 2 ** attempt)); continue; }   // not billed
    if (res.status >= 500 && !(json && json.live)) {                       // Regular search: nothing ran
      if (err.code === 'incomplete_no_results') throw err;                 // a real answer, not a fault
      await sleep(1000 * 2 ** attempt); continue;
    }
    throw err;                                                             // 4xx: fix request or account
  }
  throw err;
}

function classify(err) {
  switch (err.status) {
    case 401: return 'fix-key';
    case 402: return 'fix-billing';
    case 403: return 'fix-account';
    case 422: return 'fix-request';
    case 404: case 409: case 410: case 413: return 'fix-reference';
    case 429: return 'wait';
    case 504: return err.code === 'incomplete_no_results' ? 'incomplete' : 'service';
    default: return 'service';
  }
}

try {
  await call('POST', '/v1/search', { query: { lastName: 'Match' }, match: 'strick' });
} catch (e) {
  console.log(e.status, classify(e), e.detail);
  // 422 fix-request Unknown match 'strick'. Allowed: balanced, broad, strict.
}
```

### The bodies, from the sandbox

**401**
```json
// no key, or an unknown key
{ "detail": "Missing or invalid API key. Send it in the X-API-Key header." }

// a live key sent to the sandbox (the live host answers the mirror image for a test key)
{ "detail": "This is a live key. The sandbox at https://sandbox.offendersearch.app accepts test keys (os_test_…) only — create one in the dashboard under Developers → API keys. Send live keys to https://api.offendersearch.app." }
```

**422 sentence**
```json
// {"query": {"firstName": "Exact"}}
{ "detail": "This query has nothing to narrow on, so it cannot be answered completely: … Add at least one of: lastName, a date of birth or age, city, county, zipCode, a radius, or the free-text q. A firstName on its own is not enough to narrow a search — pair it with a surname or a date of birth. …" }

// {"query": {}}
{ "detail": "This search constrains nothing and would match every record we hold. Supply at least one of: firstName, lastName, dob, age, city, state, zipcode, q, address, lat+lng, a createdAt/updatedAt bound — or scope the call with `jurisdictions` (e.g. {\"jurisdictions\":[\"WY\"]})." }

// "match": "strick"            → { "detail": "Unknown match 'strick'. Allowed: balanced, broad, strict." }
// "jurisdictions": ["XX"]      → { "detail": "Unknown jurisdiction(s) 'XX'. Use a registry id or a two-letter USPS code — e.g. 'TX', not 'TEXAS'. GET /v1/sources lists every id we serve." }
// "recordTypes": ["sexoffender"] → { "detail": "Unknown recordType 'sexoffender'. Allowed: sex_offender. (Note the underscore: the type is `sex_offender`, not `sexoffender`.)" }
// "onDeadline": "fail"         → { "detail": "Unknown onDeadline 'fail'. Allowed: error, partial." }
// "prefixMatch" on "Ma"        → { "detail": "prefixMatch prefix matching on lastName requires at least 3 characters (got 'Ma'). Send a longer prefix, or drop it for an exact match." }
```

**422 list**
```json
// {"query": {"lastName": "Match", "dob": "1980"}}
{ "detail": [ { "type": "date_from_datetime_inexact", "loc": ["body", "query", "dob"],
                "msg": "Datetimes provided to dates should have zero time - e.g. be exact dates", "input": "1980" } ] }

// {"query": {"lastName": "Match", "zip": "00000"}}   — unknown key; the field is zipcode
{ "detail": [ { "type": "extra_forbidden", "loc": ["body", "query", "zip"], "msg": "Extra inputs are not permitted", "input": "00000" } ] }

// "deadlineMs": 10
{ "detail": [ { "type": "greater_than_equal", "loc": ["body", "deadlineMs"], "msg": "Input should be greater than or equal to 1000", "input": 10, "ctx": { "ge": 1000 } } ] }

// "clearanceMode": "hide"
{ "detail": [ { "type": "literal_error", "loc": ["body", "clearanceMode"], "msg": "Input should be 'suppress', 'annotate' or 'ignore'", "input": "hide" } ] }
```

**404 · 409 · 410 · 413**
```json
// GET /v1/searches/{id} for a synchronous searchId, an unknown id, or another account's job
{ "detail": "Not found" }
// GET /v1/records/{id}
{ "detail": "Not found. A recordId resolves while the search that returned it is retained; run the search again to get a current recordId, or look the person up by registry uuid and state." }
// POST /v1/report with a searchId this account never ran
{ "detail": "No search found for that searchId on this account." }
// POST /v1/clearances citing a search that did not return the personRef
{ "detail": { "code": "person_not_in_search", "message": "That search did not return this personRef. A clearance must cite a search that showed the person being cleared." } }
// GET /v1/searches/{id}/results after 14 days
{ "detail": "Results for this search are no longer available — we keep them for 14 days and then erase them. Run the search again to see current results." }
// POST /v1/batch with 1,001 rows
{ "detail": "Batch too large: 1001 rows (max 1000)." }
```

### The bodies the sandbox does not produce (production shapes)

**402**
```json
{ "detail": "No active subscription for this account. Start a subscription (POST /v1/billing/checkout-session) or update your payment method (POST /v1/billing/portal-session) to use the API." }
{ "detail": "Your free trial is used up (25 of 25 searches). Add a payment method to keep going — $0.15 per search, …" }
{ "detail": "Free-trial searches must include a name (first or last). …" }
{ "detail": "Billing required for proof documents." }
```

**403**
```json
// a nameless (location-only) search after the monthly pause
{ "detail": { "code": "location_quota_paused", "message": "…", "locationRecordsUsed": 45000, "locationRecordsLimit": 45000 } }

// a personal (consumer) account on a business route — NOT a detail envelope
{ "error": "use_background_check", "message": "Personal accounts check people with a Background Check.", "path": "/home/check" }
```

**429**
```json
// Retry-After: 12   — nothing was searched or billed
{ "detail": { "code": "overloaded", "message": "…", "retryAfterSeconds": 12, "queueDepth": 41, "predictedWaitSeconds": 48.5 } }

// Retry-After: 5    — no search slot within 45 seconds
{ "detail": { "code": "overloaded", "message": "…", "retryAfterSeconds": 5, "queuedSeconds": 45.0 } }

// Retry-After: 10   — live.evidence capacity
{ "detail": "evidence capacity is busy; retry shortly" }
```

**503 · 504**
```json
// live.evidence service unreachable — NOT a detail envelope; the search did not run
{ "error": { "code": "unavailable", "message": "Live evidence is temporarily unavailable. Your search was not run and nothing was billed; retry, or omit live.evidence." } }

// no records and unfinished registries (skipped when you explicitly sent onDeadline: "partial")
{ "detail": { "code": "incomplete_no_results", "message": "…", "unfinishedSources": ["CA", "FL"], "sourcesQueried": 58 } }

// onDeadline: "error" and the search came back partial
{ "detail": "Deadline hit and onDeadline=error" }
```

## Reading the result

| Status | When | Body | Retry? |
| --- | --- | --- | --- |
| `400` | `/v1/report` with neither `searchId` nor `query` | sentence | No — fix the request. |
| `401` | Missing/invalid key; test key on the live host or live key on the sandbox | sentence (branch on the status, not the wording) | No — fix the key or host. |
| `402` | No subscription; trial exhausted; trial query without a name; `proof` without billing; batch larger than the trial | sentence | No — fix billing or the request. |
| `403` | Location-record quota paused; consumer account on a business route | object with `code`, or the bare `{error, message, path}` | No. |
| `404` | Unknown/foreign `searchId`, proof token, clearance; record id never issued or aged out; report for a search not on the account | sentence; `search_not_found` object on clearances | No — says nothing about the person. |
| `409` | Ambiguous registry `uuid`; clearance cites a search that did not show the person | sentence; `person_not_in_search` object | No — use `recordId` / the right `searchId`. |
| `410` | Stored results erased (14 days); report window passed (7 days) | sentence | No — run a new search. |
| `413` | Batch over 1,000 rows | sentence | No — split the batch. |
| `422` | Validation: unknown key, wrong type, out of range, unknown enum value, query that narrows nothing, unknown jurisdiction/state, unsafe `webhookUrl`, evidence rules | list or sentence | No — fix the field named. |
| `429` | Overloaded; slot queue full; evidence capacity; sandbox rate limit | object with `retryAfterSeconds`, or a sentence; always `Retry-After` | **Yes**, after `Retry-After`. Not billed. |
| `503` | Evidence service unavailable; clearances store unavailable | `{error: {code, message}}` for evidence; sentence otherwise | Yes, later. Nothing ran or was recorded. |
| `504` | `incomplete_no_results`; `onDeadline: "error"` hit | object with `code`, or sentence | `incomplete_no_results` is an answer (unfinished registries) — re-run or send `onDeadline: "partial"`; the other is your own setting. |

## Edge cases and pitfalls

- **Do not match on `detail` text.** Wordings change and differ between routes (`Missing or invalid API key…` vs `Provide a valid X-API-Key or session token.`). Branch on the status and, where present, on `detail.code`.
- **`429` is never billed and always carries `Retry-After`** (1–120 seconds). A body with `queueDepth` was refused before anything ran. Retrying sooner is simply refused again.
- **`504 incomplete_no_results` looks like an error and is really a partial result with no records.** Sending `"onDeadline": "partial"` explicitly turns it into a `200` with `status: "partial"` that you can read with [Working with incomplete results](/docs/guides/sex-offender/incomplete-results).
- **5xx on a live search may have billed completed sources.** Retrying a Regular search after a `5xx` is safe (nothing ran); for a live search, re-run and reconcile `usage` rather than retrying blindly.
- **A test key on the live host is a `401`, not a `403`**, and the sentence names the sandbox URL. Catch it in CI by asserting the host matches the key prefix.
- **Batch rows swallow their own errors**: a refused row is `status: "error"` inside a `200`. Inspect rows, not just the status code.
- **Validation runs before load shedding**, so a `422` is always about your request, never about capacity.
- **The sandbox echoes `"sandbox": true` on error bodies too**; strip it before comparing bodies with production.

## Production checklist

- Every non-2xx logged with status, `detail`/`error`, `Retry-After` and your request id; none rendered as "no records".
- `429` backoff honours `Retry-After`; `5xx` retried only for Regular searches; `4xx` never retried unchanged.
- Async `searchId` persisted before polling; no resubmission on poll errors.
- Alerts on `402` (billing) and on repeated `incomplete_no_results` for the same registries.

## Related

- [Errors, status codes & rate limits](https://offendersearch.app/docs/errors)
- [Platform: errors & retries](https://offendersearch.app/docs/platform/errors-and-retries)
- [Working with incomplete results](https://offendersearch.app/docs/guides/sex-offender/incomplete-results)
- [Authentication, keys & security](https://offendersearch.app/docs/authentication)
