Skip to main content

Two error shapes, and how to tell them apart

Some failures are produced by the service; some are produced by the gateway in front of it, before the service is reached. They do not share a body shape.
Match on the HTTP status code, and, for service errors only, on error.
  • error is a stable machine-readable code: bad_request, payment_required, request_too_large, engine_error, not_found.
  • reason is a narrower, also stable code that appears on a 400 when the body parsed and a value in it was rejected: unknown_operating_point, unknown_record_kind, non_string_text and similar. It is absent when the body itself would not parse, so read it with a default and never require it. New reasons ship additively, so tolerate ones you do not recognise.
  • detail is for a human reading a log. It is not stable; do not parse it.
  • The gateway’s key is capitalised inconsistently between cases (message in some, Message in others) because those are the platform’s own bodies passed through unchanged. Do not build on that key at all.

The table

401 vs 403

These mean genuinely different things and are worth wiring to different alerts.

401: the header was missing entirely

No Authorization header at all. Rejected at the gateway before anything of ours runs. This is almost always a deployment mistake: an unset environment variable, a proxy stripping the header, a misspelled header name.

403: a header arrived and was refused

Anything else. Including an Authorization header that is present but not Bearer-shaped: that reaches authorization and is denied, it does not become a 401. Other causes: an unknown, revoked or malformed key; a suspended organisation; a control plane too stale to vouch for the key.
The 403 body deliberately does not say which cause applied. An error that distinguishes “no such key” from “suspended organisation” is an oracle for anyone probing keys, so the message is uniform on purpose, and the way to find out which one you hit is to ask us.
A 403 right after minting a key is usually a provisioning race, not a bad key. Associating a new key with its plan is not instantaneous; it can take a minute or two. Retry once after a short delay before concluding anything.

Retrying a 429

Both a rate limit and a daily quota surface as 429, and they want opposite behaviour: Branch on the body here if you can: it is the one place a gateway message is worth reading, because the status code alone cannot distinguish “retry shortly” from “retry tomorrow”. Treat it as a hint rather than a contract: it is the platform’s string, not ours, so default to capped exponential backoff when the body is missing or unrecognised, and let a circuit breaker stop the retries rather than relying on the message to tell you to. Your plan limits are approximate in both directions (why), so a 429 is a normal operating condition, not an incident. Build backoff in from the first day.

402: out of credit

402 means your workspace is out of credit. It is served, so handle it. It is a service error, not a gateway one, so it carries the error/detail shape and not the gateway’s message key:
Nothing about the credential is wrong when you see this, which is exactly why it is a separate status: a 403 says fix the key, a 402 says add credit. Match on the status, or on error, and never fold the two together.
Do not retry a 402 on a backoff. It will not clear until credit is added, from the dashboard’s Wallet page. Retrying turns an empty balance into a hot loop against an endpoint that will keep refusing.

The two cases that are not errors

Reading a stand-down

Needlepath can decide that trimming would not pay and hand your context back essentially intact. This is always a 200, but the shape of that 200 depends on the operating point, because np-2026-08-r3 and np-2026-08-r4 answer “trimming would not pay” differently from np-2026-07-r2. On np-2026-08-r4, the default, this is a full-context fallback: every record comes back, outcome reads "engaged", and the receipt reads usage.outcome: "pass_through". Three small records at a 4,000-token budget, one of them sent with importance: 0.0, all under real pressure to be dropped if the engine found anything worth dropping it for:
The record sent with importance: 0.0 comes back along with the other two. importance is a scoring input, not an admission ticket: on a full-context fallback nothing is dropped for any reason, low importance included. A caller pinned to the older np-2026-07-r2 sees the shape this section used to describe exclusively: an actual empty selection, outcome: "stood_down", nothing in selected[]:
Both are the same request; only budget.operating_point differs. See the same three records under all three labels for the full comparison, including np-2026-08-r3, which behaves like r4 here. Either way: send what you were going to send. This is the engine declining to make things worse, and on some workload shapes it is the common outcome rather than a rare one, so do not alarm on it and do not treat a tokens_saved of zero as a failure.
fallback_used: true does not tell you why. Four different things produce it:
  1. a deliberate stand-down: top-level outcome is stood_down;
  2. a full-context fallback on np-2026-08-r3 or np-2026-08-r4, shown above: top-level outcome is engaged, every record came back, and usage.outcome reads pass_through (or fallback if the fallback changed the token total or record count);
  3. an empty or unusable record set;
  4. an engine-internal exception: selection_error is populated.
If you care about the difference, read outcome, usage.outcome, selection_error and format_metrics.engine_fallback_reason. fallback_used alone conflates a healthy decision with an outage.
Branch on outcome, never on gate.engaged. They answer different questions and they disagree on ordinary, healthy requests.outcome is what the service did: engaged, stood_down or escalated. gate is the envelope gate’s shadow assessment, which reports what that gate would have decided. It runs in shadow at every published operating point and does not drive the result, so gate.engaged: false alongside outcome: "engaged" is a normal response, not a contradiction and not a bug.It happens routinely on the text auto-split path, where under np-2026-08-r4 the gate records standdown:source_support_missing on a request the service engages on. A client that branches on gate.engaged will read a successful selection as a stand-down and throw the saving away.gate.engaged predates outcome. Until outcome shipped it was the closest signal available, which is why older integrations reach for it. It is still published, and still useful for understanding why a decision looked marginal. It is simply not the decision.gate.signals is the gate’s own accounting and is not the response’s record counts. gate.signals.n_candidates can differ from records_available; the two are not meant to reconcile.
Two more traps in the same area:
  • safety is null on np-2026-07-r2’s plain fixed-budget path, because no coverage verdict is computed there. np-2026-08-r3 and np-2026-08-r4 compute one on the same fixed-budget path (both were observed doing so on a live capture, on requests that neither set mode: "adaptive" nor require_evidence_coverage), so under the default you will see safety populated far more often. Either way, a null safety means “no verdict ran”, never “the verdict was fine”.
  • An empty selected[] is not proof that nothing was selected. It is also empty when you sent return_per_record: false. Read records_selected.

An engine failure returns 200, not 5xx

If the selection pass itself raises, you get a 200 with selection_error populated and a usable full-context body, because the correct client behaviour in that case is to send your original context, and the body already contains it.
This decides how you monitor us. An availability metric defined on “non-5xx” will report perfect health straight through an engine outage. Alarm on selection_error being non-null, not on status code.
A 500 is reserved for the cases where no response could be produced at all. Two shapes, and they want different handling:
  • {"error": "engine_error", …} is ours, and it means something inside the service failed in a way that produced no response. Nothing a caller can put in a request is supposed to reach it: a value we do not serve is rejected as a 400 before the engine runs (see below).
  • No error key: the platform’s, produced when something in front of the service fails.
Both are worth one or two retries with capped exponential backoff, and then stop. A 500 that survives two attempts on an unchanged request is not going to clear on the third.

Rejected values are a 400, not a 500

If the body parses but carries a value we do not serve, the request is rejected before the engine runs. That means a 400, no usage block, and no charge.
Never retry these. A 400 is a permanent client error: the same request fails identically every time, and retrying it on a backoff only spends your rate limit. Fix the request. Read /v1/operating-points for valid labels, and the record kinds for valid kinds.
One size ceiling is a 400, not a 413, and only on np-2026-08-r4. A text request whose paragraphs normalize to more than 1,000 units is rejected outright under np-2026-08-r4:
np-2026-07-r2 and np-2026-08-r3 do not reject this request at all: both silently cap it at 1,000 units and return 200, and on np-2026-08-r3 the engine can then escalate on the truncated input, which is charged at the full rate. See the operating-point comparison on the limits page for all three, verified live.
These two used to be 500 engine_error, and this page used to say so. They are 400s now. If you built alarming or retry logic on the old behaviour, a 500 from these causes is no longer something to expect, and the retry loop that used to be harmless is now pointless.

Timeouts and fail-open

Set a client timeout you are happy to add to your critical path. Every response reports the server-measured selection time as engine_latency_ms; your round trip adds network transit on top of it, and the two are different measurements that should not be added together casually. Measure both from your own client before you choose a timeout; a 10-second default is generous for either. The published Python client records its own round trip as format_metrics.client_latency_ms. That key is added by the client, not by the server: do not read it as a server measurement, and do not expect it on a response you fetched yourself. Whatever the failure (timeout, non-2xx, unparseable body, empty selection), send your original context unmodified. Selection is an optimisation, and a failed optimisation degrades to “no optimisation”, never to an empty prompt. There is a worked implementation in the quickstart.