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_…) onhttps://sandbox.offendersearch.appto reproduce every422below; a live key to observe5xxbehaviour. - 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
- 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.codeanderror.message. - Map 4xx to a fix. Every
4xxexcept429means the request as sent will never succeed. Change it: addlastName, lengthen a prefix, fix a date, narrow a too-broad name, correct a filter value, choose one location form. - Map 5xx and 429 to a retry. Their messages say "NOTHING was searched and no result is implied". Wait
Retry-After(default 1 s for503 retry, 300 s forfilters_unavailable), back off exponentially, cap the attempts, and keep the request byte-identical. - 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)// 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. |
| 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's401/402. Reading it elsewhere yieldsundefined. - The gateway retries for you first. "Not taken" answers (
429,502,503, a dropped connection) are re-sent for up to 45 seconds; ambiguous500/504/read-timeouts are re-sent a few times only for GETs,/batch,/resolveand a search withoutlive. A live search or a report that times out ambiguously is returned to you as504 upstream_timeout— you decide whether to resend. - After the gateway's budget the last upstream answer is returned verbatim. That is how a
503 retrywithRetry-After: 1, or a bare429/502page, 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
200you failed to read meters anothercriminal_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
detailsentence tells you what to add. - An unknown filter key is a `422`, not ignored — but an unknown
includetoken orprefixMatchselector 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
querytypes, 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,503and504, byte-identical, honouringRetry-After, with exponential backoff and a cap. - Treat every other
4xxas a request bug surfaced to your logs witherror.codeanderror.message. - Alert on
402immediately — 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.