# Error handling

> Two body shapes, one rule: branch on `error.code` from the criminal service and on the HTTP status from the gateway. Then retry only what the message says was never searched.

- **HTML:** https://offendersearch.app/docs/guides/criminal/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'll build

One error handler for every `/v1/criminal/*` call. It must parse both body shapes, map each refusal to a fix (change the request) or a retry (resend it), respect `Retry-After`, and never confuse "refused" with "no records". Everything below is the production behaviour; the sandbox answers validation errors identically but never simulates a `5xx`.

## Before you start

- A test key (`os_test_…`) on `https://sandbox.offendersearch.app` to reproduce every `422` below; a live key to observe `5xx` behaviour.
- The reference tables: [Errors & status codes](/docs/criminal/errors) and the cross-product [Errors & retries](/docs/platform/errors-and-retries).

> **Two shapes, no 403.** The criminal service answers `{"error": {"code": "...", "message": "..."}}` (`query_too_broad` adds a top-level `detail`). The gateway answers `401` and `402` as `{"detail": "..."}` and may answer `429` with no body at all. There is no criminal `403` and no entitlement to request: Criminal Search is on every account.

## Step by step

1. **Parse by status first.** `2xx` → read the body as the endpoint documents (JSON, or PDF on `/report`). `401`/`402` → `detail`. `429` → `Retry-After`, body optional. Everything else → `error.code` and `error.message`.
2. **Map 4xx to a fix.** Every `4xx` except `429` means the request as sent will never succeed. Change it: add `lastName`, lengthen a prefix, fix a date, narrow a too-broad name, correct a filter value, choose one location form.
3. **Map 5xx and 429 to a retry.** Their messages say "NOTHING was searched and no result is implied". Wait `Retry-After` (default 1 s for `503 retry`, 300 s for `filters_unavailable`), back off exponentially, cap the attempts, and keep the request byte-identical.
4. **Reproduce the common 422s on the sandbox.** Send the bodies below; each refusal is real and identical to production.

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

API, KEY = "https://sandbox.offendersearch.app", "os_test_your_key"
RETRY_STATUSES = {429, 502, 503, 504}

class Refused(Exception):
    def __init__(self, status, code, message): super().__init__(f"{status} {code}: {message}"); self.code = code

def criminal(method, path, attempts=4, **kw):
    for n in range(attempts):
        r = requests.request(method, API + path, headers={"X-API-Key": KEY}, timeout=90, **kw)
        if r.ok:
            return r
        if r.status_code in RETRY_STATUSES:                 # nothing was searched, nothing billed
            wait = float(r.headers.get("Retry-After") or min(2 ** n, 30))
            if r.status_code == 503:
                try: code = r.json()["error"]["code"]       # retry | filters_unavailable | unavailable | catalog_unavailable
                except Exception: code = "?"
                if code == "filters_unavailable":            # Retry-After: 300 — or resend without filters
                    raise Refused(503, code, r.json()["error"]["message"])
            time.sleep(wait); continue
        if r.status_code in (401, 402):
            raise Refused(r.status_code, "gateway", r.json()["detail"])
        body = r.json()                                     # 400 / 404 / 413 / 422: fix the request
        raise Refused(r.status_code, body["error"]["code"], body["error"]["message"])
    raise TimeoutError("still failing after retries; keep the request and try later")

for body in ({"query": {"firstName": "Felony"}},                                   # guard_unbounded_query
             {"query": {"lastName": "Record", "firstName": "Felony", "dob": "06/15/1975"}},  # invalid_dob
             {"query": {"lastName": "Record"}, "perPage": 300},                   # invalid_page
             {"query": {"lastName": "Record", "state": "OH"}, "locations": [{"state": "OH"}]},  # ambiguous_location
             {"query": {"lastName": "Record"}, "filters": {"severity": ["capital"]}},  # invalid_filter_value
             {"query": {"lastName": "Record"}, "live": True}):                    # invalid_live
    try:
        criminal("POST", "/v1/criminal/search", json=body)
    except Refused as e:
        print(e)
```

**JavaScript**
```javascript
const API = 'https://sandbox.offendersearch.app';
const KEY = 'os_test_your_key';
const RETRY = new Set([429, 502, 503, 504]);
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));

class Refused extends Error { constructor(status, code, message) { super(status + ' ' + code + ': ' + message); this.code = code; } }

async function criminal(method, path, body, attempts = 4) {
  for (let n = 0; n < attempts; n++) {
    const r = await fetch(API + path, {
      method, headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    if (r.ok) return r;
    if (RETRY.has(r.status)) {                                  // nothing was searched, nothing billed
      const ra = r.headers.get('Retry-After');
      let code = '?';
      if (r.status === 503) { try { code = (await r.clone().json()).error.code; } catch {} }
      if (code === 'filters_unavailable') throw new Refused(503, code, (await r.json()).error.message); // Retry-After: 300
      await sleep((ra ? Number(ra) : Math.min(2 ** n, 30)) * 1000);
      continue;
    }
    if (r.status === 401 || r.status === 402) throw new Refused(r.status, 'gateway', (await r.json()).detail);
    const err = (await r.json()).error;                         // 400 / 404 / 413 / 422: fix the request
    throw new Refused(r.status, err.code, err.message);
  }
  throw new Error('still failing after retries; keep the request and try later');
}

for (const body of [
  { query: { firstName: 'Felony' } },                                                     // guard_unbounded_query
  { query: { lastName: 'Record', firstName: 'Felony', dob: '06/15/1975' } },             // invalid_dob
  { query: { lastName: 'Record' }, perPage: 300 },                                        // invalid_page
  { query: { lastName: 'Record', state: 'OH' }, locations: [{ state: 'OH' }] },           // ambiguous_location
  { query: { lastName: 'Record' }, filters: { severity: ['capital'] } },                  // invalid_filter_value
  { query: { lastName: 'Record' }, live: true },                                          // invalid_live
]) {
  try { await criminal('POST', '/v1/criminal/search', body); } catch (e) { console.log(e.message); }
}
```

*The bodies you will see*
```json
// 422 from the criminal service — branch on error.code
{ "error": { "code": "guard_unbounded_query", "message": "a lastName predicate is required — an unbounded query returns a lottery, not an answer" } }
{ "error": { "code": "invalid_dob", "message": "dob must be a date in 'YYYY-MM-DD' form" } }
{ "error": { "code": "invalid_page", "message": "perPage must be from 1 to 200 (got 300)" } }
{ "error": { "code": "invalid_filter_value", "message": "filters.severity: capital not allowed; use one of felony, misdemeanor, infraction" } }
{ "error": { "code": "invalid_live", "message": "live must be a JSON object, not a JSON boolean" } }
{ "error": { "code": "query_too_broad", "message": "This query is too broad to be answered completely: …" }, "detail": "This query is too broad to be answered completely: …" }

// 401 / 402 from the gateway — one sentence in detail
{ "detail": "Provide a valid X-API-Key or session token." }
{ "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." }

// 503 / 504 — nothing was searched; resend the same request
{ "error": { "code": "retry", "message": "Criminal Search could not run this search just now, so NOTHING was searched and no result is implied. Retry the same request — it is expected to succeed." } }
{ "error": { "code": "upstream_timeout", "message": "Criminal Search took too long to respond, so NOTHING was returned and no result is implied. Retry the same request." } }
```

## Reading the result

| HTTP | `code` | Fires when | Do |
| --- | --- | --- | --- |
| 401 | — (`detail`) | No or invalid key; a test key on the live host (the sentence names the sandbox URL). | Fix the key or host. |
| 402 | — (`detail`) | Trial used up, or no active subscription. | Subscribe or update payment at [Billing](/dashboard/billing). |
| 400 | `no_report_input` | `/report` with neither `searchId` nor a query. | Send one of the two. |
| 404 | `not_found` | Unknown or another account's `searchId`; a record id that is not current; a pending job on `/report`. | Check the id and key; poll a job to `200` before rendering. |
| 413 | `batch_too_large` | More than 1,000 batch rows. | Chunk at 1,000. |
| 422 | `guard_unbounded_query` | No `lastName`. | Add the surname. |
| 422 | `invalid_body` · `invalid_query` · `invalid_live` | Body not a JSON object or empty; `query` not an object or a text field of the wrong type; `live` truthy but not an object. | Fix the shape. |
| 422 | `prefix_too_short` | A field named in `prefixMatch` with a value under 3 characters. | Lengthen it or drop `prefixMatch`. |
| 422 | `invalid_dob` | `dob` not `YYYY-MM-DD`. | Reformat the date. |
| 422 | `invalid_page` | `page < 1`, `perPage` outside 1–200, or not an integer. | Correct the values; nothing is clamped. |
| 422 | `no_live_sources` | A `live` block that resolved to nothing and no websites. | Send real codes from `/v1/criminal/jurisdictions`, or drop `live`. |
| 422 | `query_too_broad` (+ `detail`) | The name and filters match more stored records than one search can read in full within its budget. Also on a refused async poll; a batch row carries it as `errorCode`. | Add `firstName` or `state`. Do not retry unchanged. |
| 422 | `invalid_filter_value` · `invalid_date_range` | A filter value outside its list, an unknown filter key, a bad `sort`/`unknown`/`chargeMatch`; a date range not ISO, impossible, or backwards. | The message lists the allowed values. |
| 422 | `invalid_location` · `too_many_locations` · `ambiguous_location` · `radius_too_large` | A malformed or empty `locations` entry; more than 10; `locations` with flat place fields; `radiusMiles > 100`. | Fix the location form. |
| 422 | `filters_with_async_live` | `filters`/`locations`/`sort`/`facets` on a live search that must run async. | Narrow `live.jurisdictions` to ≤2 or filter the result yourself. |
| 422 | `no_website` · `bad_batch` · `empty_batch` | `/resolve` without `website`/`websites`; an unparseable batch or CSV with no header; zero rows. | Fix the body. |
| 429 | — (`Retry-After`; body may be absent) | The gateway queued the request for up to 45 seconds and the service stayed busy. | Wait `Retry-After`; resend unchanged. |
| 503 | `retry` (`Retry-After: 1`) | The service could not run the search; normally absorbed by the gateway's queue, visible after it. | Resend unchanged with backoff. |
| 503 | `filters_unavailable` (`Retry-After: 300`) | Filtering is switched off; a filtered request is never answered unfiltered. | Retry after 300 s, or resend without `filters`/`locations`. |
| 503 | `unavailable` · `catalog_unavailable` | The service could not be reached; the source catalog could not be read. | Resend with backoff. |
| 504 | `upstream_timeout` | No upstream answer within the gateway's budget (about 75 s). | Resend unchanged; for a live search, consider fewer sources. |

## Edge cases and pitfalls

- **Never read `detail` on a criminal `422`.** It exists only on `query_too_broad` (as a copy of the message) and on the gateway's `401`/`402`. Reading it elsewhere yields `undefined`.
- **The gateway retries for you first.** "Not taken" answers (`429`, `502`, `503`, a dropped connection) are re-sent for up to 45 seconds; ambiguous `500`/`504`/read-timeouts are re-sent a few times only for GETs, `/batch`, `/resolve` and a search **without** `live`. A live search or a report that times out ambiguously is returned to you as `504 upstream_timeout` — you decide whether to resend.
- **After the gateway's budget the last upstream answer is returned verbatim.** That is how a `503 retry` with `Retry-After: 1`, or a bare `429`/`502` page, can reach you. Branch on the HTTP status, not on the presence of a JSON body.
- **A `5xx` or `429` is never billed; a `200` always is.** Resending after a `200` you failed to read meters another `criminal_call`. Persist the response before you act on it.
- **`query_too_broad` is a `422`, not a `5xx`.** Retrying it unchanged fails again every time; the `detail` sentence tells you what to add.
- **An unknown filter key is a `422`, not ignored** — but an unknown `include` token or `prefixMatch` selector is silently ignored. Spell-check both in tests.
- **Sandbox limits are real `429`s.** 60 requests a minute and 2,000 a day per test key, 120 a minute per IP, with `Retry-After`. They protect the sandbox and are not a simulation of production.
- **Validation order.** The body shape is checked, then `query` types, then the surname guard, then prefix length, dates, paging, `live`, filters and locations. Fix the first error you see and resend; you may meet the next.

## Production checklist

- Centralise the handler: status → shape → `error.code` → fix-or-retry; share it across search, poll, batch, report and resolve.
- Retry only `429`, `502`, `503` and `504`, byte-identical, honouring `Retry-After`, with exponential backoff and a cap.
- Treat every other `4xx` as a request bug surfaced to your logs with `error.code` and `error.message`.
- Alert on `402` immediately — every search is refused until billing is fixed.
- Keep concurrency modest: the gateway queues, but a burst of common-surname searches with 200-record pages is the usual way to meet `429`.

## Related

- [Errors & status codes](https://offendersearch.app/docs/criminal/errors): the reference
- [Errors & retries across products](https://offendersearch.app/docs/platform/errors-and-retries)
- [Filtering & locations](https://offendersearch.app/docs/criminal/filtering): the filter and location codes
- [Access & keys](https://offendersearch.app/docs/criminal/authentication)
