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,request_too_large,engine_error,not_found.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: not serving yet
402 is reserved for “your organisation is out of credit”, and is documented
here ahead of time so client libraries reserve the case rather than folding it
into 403. Until it ships, a credit problem does not produce a distinct status.
Reserve the case; do not test against it. Nothing returns
402 today.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 a200 with:
tokens_saved of zero as a
failure.
Two more traps in the same area:
safetyisnullon the plain fixed-budget path, because no coverage verdict is computed there. 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", …}— ours. If the cause is one of the two rough edges below (an unresolvable operating point, an unknownkind) it is deterministic and retrying it unchanged will fail identically. Fix the request instead.- No
errorkey — the platform’s, produced when something in front of the service fails. Retry that one with capped exponential backoff.
500 at most a
couple of times with backoff, and stop. A 500 that survives two attempts on an
unchanged request is not going to clear on the third.
Two rough edges worth knowing
Both currently surface as a500 where a 400 would be more informative. They
are behaviour, not intent — documented so you can recognise them in a log rather
than debug them twice.
Neither is retryable: the same request fails identically. Read
/v1/operating-points for valid labels, and the
record kinds for valid kinds.
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.
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.