Criminal Records API · v1.0.0

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.

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 and the cross-product Errors & 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.
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)
The bodies you will see
// 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 whenDo
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.
400no_report_input/report with neither searchId nor a query.Send one of the two.
404not_foundUnknown 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.
413batch_too_largeMore than 1,000 batch rows.Chunk at 1,000.
422guard_unbounded_queryNo lastName.Add the surname.
422invalid_body · invalid_query · invalid_liveBody 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.
422prefix_too_shortA field named in prefixMatch with a value under 3 characters.Lengthen it or drop prefixMatch.
422invalid_dobdob not YYYY-MM-DD.Reformat the date.
422invalid_pagepage < 1, perPage outside 1–200, or not an integer.Correct the values; nothing is clamped.
422no_live_sourcesA live block that resolved to nothing and no websites.Send real codes from /v1/criminal/jurisdictions, or drop live.
422query_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.
422invalid_filter_value · invalid_date_rangeA 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.
422invalid_location · too_many_locations · ambiguous_location · radius_too_largeA malformed or empty locations entry; more than 10; locations with flat place fields; radiusMiles > 100.Fix the location form.
422filters_with_async_livefilters/locations/sort/facets on a live search that must run async.Narrow live.jurisdictions to ≤2 or filter the result yourself.
422no_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.
503retry (Retry-After: 1)The service could not run the search; normally absorbed by the gateway's queue, visible after it.Resend unchanged with backoff.
503filters_unavailable (Retry-After: 300)Filtering is switched off; a filtered request is never answered unfiltered.Retry after 300 s, or resend without filters/locations.
503unavailable · catalog_unavailableThe service could not be reached; the source catalog could not be read.Resend with backoff.
504upstream_timeoutNo 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.

Product overview