Working with incomplete results
An incomplete search is labelled, never silently short. This guide turns the labels into a retry policy.
When to use this
Most searches finish: all 58 registries answer and status is complete. When one does not — a registry was slow, a common-name query matched more candidate rows than one search may examine, a backend fault — the response says so in three places at once: status: "partial", counts.sourcesIncomplete, and a per-registry sourceStatus[].incompleteReason. You will build the branch that reads those, decides whether the answer is usable, and retries only the registries whose reason says a retry will help.
Before you start
- The sandbox never answers
partial,429or504— production does not answer that way for the fixed test people, so neither does the sandbox. The partial and error bodies in this guide are the production shapes from the contract; test the branch with a fixture in your own suite. incompleteReasonis a closed vocabulary of six values:deadline,truncated,excluded,not_searched,unavailable,error. It is non-null exactly whensourceStatus[].incompleteistrue.- Two request fields shape the behaviour:
deadlineMs(1000–600000, default 120000) andonDeadline(partialdefault, orerror).
sourceStatus[].status: "ok" does not mean the registry finished. status says the registry responded; incomplete says whether it was searched to the end. A registry that timed out still reads ok. Branch on incomplete and incompleteReason, never on status or on the free-text note.Step by step
- Send `onDeadline` explicitly. Decide what you want when a registry cannot finish.
"onDeadline": "partial"(sent explicitly) returns whatever completed and opts out of the empty-result504;"onDeadline": "error"turns a forced-partial answer into a504so nothing partial ever reaches your code. - Read the envelope. If
statusispartial,records[]is a lower bound. Collect the rows withincomplete: trueand group them byincompleteReason. - Retry what retrying fixes. Re-run the same request (or raise
deadlineMs) fordeadline,unavailableanderror; narrow the query fortruncated; remove the option forexcluded; schedule later fornot_searched. - Handle the two error statuses. A
504withdetail.code: "incomplete_no_results"means the search found nothing before some registries finished; a429withdetail.code: "overloaded"means the service refused or could not finish under load. Both carry the registries involved; neither is a clear answer.
Step 1 — the request
{
"query": { "firstName": "Jordan", "lastName": "Rivera", "dob": "1988-04-12" },
"deadlineMs": 120000,
"onDeadline": "partial"
}deadlineMs is a target, not a cut-off: since 2026-10-15 a search that can still finish keeps working past it, up to the roughly 40 seconds the gateway allows, so that you get a complete answer rather than a partial one. Values above ~40 s do not extend the search; values below 1000 or above 600000 are a 422.
Step 2 — a partial answer (production shape)
{
"searchId": "srch_2b7e4c91d0a3f58e6c1b9d47",
"status": "partial",
"counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 58, "sourcesComplete": 56, "sourcesIncomplete": 2, "sourcesSkippedByScope": 0 },
"sourceStatus": [
{ "source": "CA", "status": "ok", "matched": 0, "fromCache": true, "note": null, "incomplete": true, "incompleteReason": "deadline", "cappedOmitted": false },
{ "source": "FL", "status": "ok", "matched": 0, "fromCache": true, "note": null, "incomplete": true, "incompleteReason": "truncated", "cappedOmitted": false },
{ "source": "OH", "status": "ok", "matched": 1, "fromCache": true, "note": null, "incomplete": false, "incompleteReason": null, "cappedOmitted": false }
],
"records": [ { "recordId": "rec_28db9639d712c4c61e8e", "matchState": "dob_match", "matchConfidence": 0.9 } ],
"warnings": [
"INCOMPLETE SEARCH: 2 of 58 registries could not be searched to completion (CA, FL). Records from those registries may be missing; this result is a lower bound."
]
}One registrant was found in OH, and two registries did not finish. The right outcome is both: the OH candidate is real and must be reviewed, and the answer is a lower bound. Report candidates with the incomplete list attached; never clear. If records had been empty, the outcome would be incomplete, not clear.
Step 3 — the retry policy in code
import os, time, requests
BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")
H = {"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]}
RETRY_SAME = {"deadline", "unavailable", "error"} # a second identical pass can finish these
def search(body):
r = requests.post(BASE + "/v1/search", headers=H, json={**body, "onDeadline": "partial"}, timeout=90)
if r.status_code == 429:
raise RuntimeError("overloaded; wait Retry-After=%s" % r.headers.get("Retry-After"))
if r.status_code == 504:
raise RuntimeError("incomplete_no_results: %s" % r.json()["detail"])
r.raise_for_status()
return r.json()
def screen(body, passes=2):
result = search(body)
for _ in range(passes - 1):
if result["status"] == "complete":
break
unfinished = [s for s in result["sourceStatus"] if s["incomplete"]]
if not any(s["incompleteReason"] in RETRY_SAME for s in unfinished):
break # truncated / excluded / not_searched: retrying will not help
time.sleep(2)
again = search({**body, "jurisdictions": [s["source"] for s in unfinished]})
result["records"] = merge(result["records"], again["records"])
result["sourceStatus"] = [s for s in result["sourceStatus"] if not s["incomplete"]] + again["sourceStatus"]
result["status"] = "complete" if not any(s["incomplete"] for s in result["sourceStatus"]) else "partial"
unfinished = [s["source"] for s in result["sourceStatus"] if s["incomplete"]]
reasons = {s["source"]: s["incompleteReason"] for s in result["sourceStatus"] if s["incomplete"]}
if result["records"]:
return {"outcome": "candidates", "records": result["records"], "unfinished": unfinished, "reasons": reasons}
return {"outcome": "clear" if not unfinished else "incomplete", "unfinished": unfinished, "reasons": reasons}
def merge(a, b):
seen = {r["personRef"] for r in a}
return a + [r for r in b if r["personRef"] not in seen]The second pass re-runs only the unfinished registries by naming them in jurisdictions, so it is cheap and does not disturb the registries that already answered. Two passes is a reasonable ceiling; a registry that is still unavailable after that is alerting on the platform side and should be recorded as unfinished, not retried in a loop.
Step 4 — the two error statuses (production shapes)
{
"detail": {
"code": "incomplete_no_results",
"message": "The search found no records, but 3 of 58 registries did not finish; this is not evidence that the person is unregistered.",
"unfinishedSources": ["CA", "FL", "NY"],
"sourcesQueried": 58
}
}The 504 incomplete_no_results fires only when records would have been empty and a registry stopped for deadline or truncated — the one combination that looks like a clear and is not. Sending "onDeadline": "partial" explicitly opts out of it and gives you the partial body instead. Under load, both this case and onDeadline: "error" answer 429 with Retry-After rather than 504; a 429 was not billed and the same request is safe to retry after the wait.
Reading the result
| `incompleteReason` | What happened | What to do |
|---|---|---|
deadline | The registry did not answer within its per-registry deadline. | Retry the same request, or raise deadlineMs. |
truncated | The query matched more candidate rows than one search may examine; only the first window was searched and it is not ranked. | Do not retry. Narrow: add a surname, a firstName, a dob/age, a place, or name registries. |
excluded | A retired option on your request dropped this registry (onStale: "omit"). | Remove the option. |
not_searched | Nothing was available to search for this registry; a coverage failure that already alerts. | Treat as a lower bound; re-run later, not immediately. |
unavailable | A transient backend or transport fault reading this registry. | Retry. |
error | The registry returned an error for this request. | Retry with backoff; a persistent one is alerting. |
| Envelope field | Read it as |
|---|---|
status | complete ⇔ sourcesComplete == sourcesQueried. partial covers everything from one slow registry to almost none answering. |
counts.sourcesComplete / sourcesQueried | The ratio to build policy on: accept a high one, hold-and-retry a low one. Do not compute it as sourcesQueried − sourcesIncomplete; restricted and no_coverage rows set no incomplete flag. |
warnings[0] | Begins INCOMPLETE SEARCH: and names the registries, for logs and reviewers. Prose; branch on the enum. |
capped / cappedOmitted | A different condition: the registries finished, the response was too large. Narrow or paginate; see Pagination. |
Edge cases and pitfalls
- `partial` is never about data age. A registry read ten minutes ago can be incomplete; one read yesterday was searched to the end and its zero is real. Currency is
GET /v1/sources→health.lastSuccessAtandrecord.sources[].lastCheckedAt. - `broad` at national scope is the common cause of `truncated` — it cannot use the indexed surname range. Scope it or use
balanced. - A `dob` is never what gets cut. Under
truncated, records whose birth year matches yours are examined first; the cut falls on the unknown-birth-date group. - Records plus incomplete registries is a real state. Do not resolve it to one word; carry both the candidates and the unfinished list.
- `onDeadline` must be spelled exactly:
"fail"is a422(Unknown onDeadline 'fail'. Allowed: error, partial.). - Live rows have their own partials: a failed live source is
status: "partial",incomplete: true,fromCache: false, and is not billed. See Regular vs. live searches. - Batch rows carry no `sourceStatus`. A row's
counts.sourcesIncompleteandwarnings[]are its only completeness signals.
Production checklist
onDeadlinesent explicitly on every search.- The
partialbranch exists, is tested with a fixture, and never reportsclear. - Retry logic keyed on
incompleteReason; at most two passes;429honoursRetry-After. - Unfinished registries are stored with the outcome and visible to reviewers.
Related products
Product overview
- Sex offender search API
- Sex offender registry API
- Offender search API
- Background check API
- Bulk sex offender search
- Background check API pricing
- Background check API integration
- Integrations
- Free background check API
- Pay-as-you-go background check API
- Background check API in Python
- Background check API in Node.js
- NSOPW API