Handling partial results
A cached search is always complete. A live check can fall short at one source, and when it does the envelope says exactly where, why, and whether a retry is worth it.
What you'll build
The completeness branch of a live-verification integration: code that never treats records: [] as "clear" until it has checked that every source finished, that knows which incompleteReason values are worth a retry and which are not, and that tells a reviewer which sources did and did not answer. The discipline is the same as the Sex Offender API's, with one addition particular to criminal: a source that could not be checked live may be answered from its stored copy instead, and the row says so.
Before you start
- A live key, eventually. The sandbox never simulates a short source — every requested sandbox source answers complete — so the
partialbranch can be exercised only againsthttps://api.offendersearch.appwith codes fromGET /v1/criminal/jurisdictions?live=true. Build and unit-test the branch against the bodies below. - The vocabulary: Completeness & source status and the Cached vs. live verification guide.
status: "partial" means at least one live source has incomplete: true. The records you did get are real; the ones you did not get are unknown. An empty records[] on a partial answer is not evidence of absence. A cached search (no live block) is never partial.Step by step
- Check status before records.
complete→ every live source finished (or was answered from file).partial→ walksourceStatus[]. - Classify each sourceStatus row.
ok: true, incomplete: false, live: true→ searched live to the end.incomplete: true→ readincompleteReason.live: false, answeredFrom: "on_file"→ the live check did not run and the stored copy answered;liveOutcomesays why. - Read warnings[]. One sentence per condition, loudest first:
INCOMPLETE SEARCH:names the short sources;LIVE CHECK NOT AVAILABLE:names the on-file fallbacks;WEBSITE NOT COVERED:names an unsupportedquery.website.[]is a valid completeness check. - Decide: retry, narrow, or accept. Transient reasons → resend the same request. Structural reasons → accept the stored copy or drop the source.
truncated_over_cap→ narrow the query.
import requests
API, KEY = "https://api.offendersearch.app", "os_live_your_key" # the sandbox never produces partial
RETRYABLE = {"timeout", "source_down", "rate_limited", "captcha_unsolved", "proxy_required"}
def classify(env):
"""Return (outcome, rows_to_retry, notes) for a live search envelope."""
retry, notes = [], []
for row in env["sourceStatus"]:
code = row["code"] or "(unsupported site)"
if row.get("answeredFrom") == "on_file":
notes.append(f"{code}: answered from the stored copy (live check {row.get('liveOutcome')})")
elif row["incomplete"]:
why = row["incompleteReason"]
notes.append(f"{code}: incomplete — {why}")
if why in RETRYABLE:
retry.append(code)
else:
notes.append(f"{code}: searched live, {row['records']} record(s)")
if env["status"] == "complete":
outcome = "clear" if env["counts"]["records"] == 0 else "records"
else:
outcome = "incomplete" if env["counts"]["records"] == 0 else "records_lower_bound"
return outcome, retry, notes
body = {"query": {"lastName": "Rivera", "firstName": "Jordan", "dob": "1988-04-12", "state": "TX"},
"live": {"jurisdictions": ["TX-BEXAR", "TX-DOC"]}}
r = requests.post(f"{API}/v1/criminal/search", headers={"X-API-Key": KEY}, json=body, timeout=90)
r.raise_for_status() # a 202 would need the async poller
env = r.json()
outcome, retry, notes = classify(env)
print(outcome, "| retry:", retry)
print("\n".join(env["warnings"] + notes))
if retry:
env2 = requests.post(f"{API}/v1/criminal/search", headers={"X-API-Key": KEY}, json=body, timeout=90).json()
print("after retry:", env2["status"], env2["counts"]["sourcesIncomplete"], "still incomplete"){
"status": "partial",
"counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 2, "sourcesComplete": 1, "sourcesIncomplete": 1,
"candidatesCapped": false, "exclusionsIncluded": false },
"warnings": [
"INCOMPLETE SEARCH: 1 of 2 sources could not be searched to completion (TX-DOC). The records below are a LOWER BOUND: an absent person may simply not have been reached. This is NOT evidence that someone is absent — retry, or narrow the query."
],
"sourceStatus": [
{ "code": "TX-BEXAR", "source": "TX-BEXAR", "ok": true, "incomplete": false, "incompleteReason": null, "records": 1, "lastCheckedAt": "2026-10-25T15:04:11+00:00", "live": true },
{ "code": "TX-DOC", "source": "TX-DOC", "ok": false, "incomplete": true, "incompleteReason": "timeout", "records": 0, "lastCheckedAt": "2026-10-25T15:04:11+00:00", "live": true }
],
"records": [ { "recordCategory": "jail", "liveChecked": true, "sources": [ { "code": "TX-BEXAR", "live": true, "…": "…" } ], "…": "…" } ],
"usage": { "liveChecks": 1, "liveChargeUsd": 0.02, "capped": false, "display": "1 live checks · $0.02" },
"meterEvents": [ { "kind": "criminal_call", "quantity": 1, "chargeCeilingUsd": null },
{ "kind": "criminal_live_source", "quantity": 1, "chargeCeilingUsd": 2.0 } ]
}{
"status": "complete",
"counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 2, "sourcesComplete": 2, "sourcesIncomplete": 0, "…": "…" },
"warnings": [
"LIVE CHECK NOT AVAILABLE: TX-DOC could not be checked live (not_searched) and was answered from the stored copy. …"
],
"sourceStatus": [
{ "code": "TX-BEXAR", "source": "TX-BEXAR", "ok": true, "incomplete": false, "incompleteReason": null, "records": 1, "lastCheckedAt": "2026-10-25T15:04:11+00:00", "live": true },
{ "code": "TX-DOC", "source": "TX-DOC", "ok": true, "incomplete": false, "incompleteReason": null, "records": 0, "lastCheckedAt": "2026-10-25T15:04:11+00:00",
"live": false, "answeredFrom": "on_file", "liveOutcome": "not_searched" }
],
"usage": { "liveChecks": 1, "liveChargeUsd": 0.02, "capped": false, "display": "1 live checks · $0.02" }
}In the second body the Texas prison system was not checked live (it has no live connector in scope, hence not_searched), but its stored records were in the query's state scope, so the row was answered from file: ok: true, incomplete: false, live: false, not billed — usage.liveChecks counts only the one real live check. The warnings[] sentence is quoted in shape; read the real one from the response.
Reading the result
| Field | Values | Decision |
|---|---|---|
status | complete · partial | partial iff any row has incomplete: true. The gate for "may I call this clear". |
counts.sourcesQueried / sourcesComplete / sourcesIncomplete | integers | Live rows, finished rows, short rows. sourcesIncomplete > 0 → lower bound. |
sourceStatus[].code | public code or null | null only on an unsupported_site row from live.websites. |
sourceStatus[].ok / incomplete | booleans | ok = reached and read; incomplete = not searched to the end. Read incomplete, not ok, for completeness. |
sourceStatus[].incompleteReason | timeout · source_down · rate_limited · captcha_unsolved · proxy_required · paid_gateway · login_required · truncated_over_cap · unsupported_site · not_searched | Transient (first five): resend the same request. Structural (paid_gateway, login_required, unsupported_site, not_searched): a retry will not help. truncated_over_cap: narrow the query. Non-null only when incomplete. |
sourceStatus[].records | integer | Matcher survivors from this source; a lower bound on an incomplete row. |
sourceStatus[].live | boolean | true = checked live now. false = on-file fallback row. |
sourceStatus[].answeredFrom / liveOutcome | on_file / an incompleteReason token | Present only on a fallback row. The stored copy answered; liveOutcome is why the live check did not run (not_searched when the source has no live connector). |
warnings[] | INCOMPLETE SEARCH: · LIVE CHECK NOT AVAILABLE: · WEBSITE NOT COVERED: | Human sentences, loudest first. Show them to the reviewer verbatim. |
usage.liveChecks | integer | Completed live checks only. Failed and on-file rows are not billed. |
Edge cases and pitfalls
- The service already retries transient failures once. Within a synchronous live request, sources that failed with
source_down,timeout,captcha_unsolvedorrate_limitedare re-tried once inside a 20-second window before the answer is built. Apartialyou receive has therefore already survived one retry; back off before yours. - `not_searched` is immediate, not a timeout. A source with no live connector, or a job that hit its source cap, reports
not_searchedat once. CheckGET /v1/criminal/jurisdictions?live=truebefore asking for it live, or accept the on-file answer. - An entirely unsupported `live.websites` list is `200 partial`, not `422`. Each site becomes a
code: nullrow withunsupported_site;resolution[]says what each mapped to. One unrecognised jurisdiction code among good ones is anok: falserow; a list where nothing resolves is422 no_live_sources. - A complete job can hold a partial result. On an async job, the poll's JobRecord says
status: "complete"whileresult.statusmay bepartial. Read the inner status. - `records[]` on a partial answer are real. Act on what you have — a
dob_matchfrom the source that did answer is a finding — and separately record which sources are outstanding. - Partial is never billed for the failed source.
meterEvents[].criminal_live_source.quantityandusage.liveCheckscount completed sources only; thecriminal_callis still metered. - Batch is never partial. No live in batch, so a
partialrow cannot occur; a row that could not run isnot_runinstead.
Production checklist
- Gate "clear" on
status == "complete"andcounts.records == 0; anything else is "records" or "incomplete". - Retry transient
incompleteReasonvalues once with backoff; log structural ones and move on. - Persist
sourceStatus[]andwarnings[]with the decision; a reviewer must be able to see which sources answered. - Treat an
answeredFrom: "on_file"row as a stored-copy answer in your outcome text, not as a live confirmation. - Alert on a rising share of
partialanswers for a source you depend on; pair it with the catalog'shealth.