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.error.
erroris a stable machine-readable code:bad_request,payment_required,request_too_large,engine_error,not_found.reasonis a narrower, also stable code that appears on a400when the body parsed and a value in it was rejected:unknown_operating_point,unknown_record_kind,non_string_textand 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.detailis for a human reading a log. It is not stable; do not parse it.- The gateway’s key is capitalised inconsistently between cases (
messagein some,Messagein 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.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 as429, 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:
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 a200, 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:
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[]:
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.
Two more traps in the same area:
safetyisnullonnp-2026-07-r2’s plain fixed-budget path, because no coverage verdict is computed there.np-2026-08-r3andnp-2026-08-r4compute one on the same fixed-budget path (both were observed doing so on a live capture, on requests that neither setmode: "adaptive"norrequire_evidence_coverage), so under the default you will seesafetypopulated far more often. Either way, a nullsafetymeans “no verdict ran”, never “the verdict was fine”.- An empty
selected[]is not proof that nothing was selected. It is also empty when you sentreturn_per_record: false. Readrecords_selected.
An engine failure returns 200, not 5xx
If the selection pass itself raises, you get a200 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.
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 a400before the engine runs (see below).- No
errorkey: the platform’s, produced when something in front of the service fails.
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 a400, no usage block, and no charge.
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.
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 asengine_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.