Sex Offender Search API · v1.0.0

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, 429 or 504 — 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.
  • incompleteReason is a closed vocabulary of six values: deadline, truncated, excluded, not_searched, unavailable, error. It is non-null exactly when sourceStatus[].incomplete is true.
  • Two request fields shape the behaviour: deadlineMs (1000–600000, default 120000) and onDeadline (partial default, or error).
The one thing people get wrong. 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

  1. 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-result 504; "onDeadline": "error" turns a forced-partial answer into a 504 so nothing partial ever reaches your code.
  2. Read the envelope. If status is partial, records[] is a lower bound. Collect the rows with incomplete: true and group them by incompleteReason.
  3. Retry what retrying fixes. Re-run the same request (or raise deadlineMs) for deadline, unavailable and error; narrow the query for truncated; remove the option for excluded; schedule later for not_searched.
  4. Handle the two error statuses. A 504 with detail.code: "incomplete_no_results" means the search found nothing before some registries finished; a 429 with detail.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

POST /v1/search body
{
  "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)

200 with status partial (trimmed to the rows that matter)
{
  "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 happenedWhat to do
deadlineThe registry did not answer within its per-registry deadline.Retry the same request, or raise deadlineMs.
truncatedThe 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.
excludedA retired option on your request dropped this registry (onStale: "omit").Remove the option.
not_searchedNothing was available to search for this registry; a coverage failure that already alerts.Treat as a lower bound; re-run later, not immediately.
unavailableA transient backend or transport fault reading this registry.Retry.
errorThe registry returned an error for this request.Retry with backoff; a persistent one is alerting.
Envelope fieldRead it as
statuscomplete ⇔ sourcesComplete == sourcesQueried. partial covers everything from one slow registry to almost none answering.
counts.sourcesComplete / sourcesQueriedThe 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 / cappedOmittedA 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.lastSuccessAt and record.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 a 422 (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.sourcesIncomplete and warnings[] are its only completeness signals.

Production checklist

  • onDeadline sent explicitly on every search.
  • The partial branch exists, is tested with a fixture, and never reports clear.
  • Retry logic keyed on incompleteReason; at most two passes; 429 honours Retry-After.
  • Unfinished registries are stored with the outcome and visible to reviewers.

Product overview