When HTTP 200 does not mean success
A runtime that trusts the status line is wrong about a large family of APIs:
Left alone, the model receives an error payload as if it were a success and
reports the message sent. The mistake is silent and downstream.
Writing a per-tool check is not the fix. A check you have to remember to add is
one that eventually gets forgotten — and it puts the same code in every pack. The
success predicate is part of the API’s contract, so it is declared like the rest
of the contract: once, and the runtime enforces it.
Declaring one
AnEnvelope goes on the
factory, beside the rest of the API’s contract:
slack_envelope.py
ok_field must be truthy. For APIs that report a word
rather than a bool, add ok_value:
errors_field holding a non-empty list means failure.
This is the GraphQL convention, and it ships ready-made:
GRAPHQL_ENVELOPE is
that declaration, exported ready to use.
What it raises
credential_errors decides which of the two typed errors comes out, so a host
application can tell “refresh the token and retry” from “the request was wrong”:
- code in
credential_errors→CredentialError, tagged with the provider - anything else →
APIError
APIError.status_code is 200 on purpose. That really was the status, and it is
the fact that surprises whoever reads the log later.
Where it runs
Per-endpoint override
Rare, but some APIs are inconsistent across endpoints:per_endpoint_envelope.py
Scope
An envelope decides whether a response is a failure and what to raise. It does not retry, does not inspect HTTP status (that is handled before it), and does not reshape a successful payload — that is a response handler’s job.Related
- Tool validation and error handling
- Writing a pack
Envelope— every field, the path syntax, the methodsResponseHandler— what shapes a payload once it has passed
When failure is not at the root
Everything above assumes the failure flag sits at the top of the response. For a GraphQL mutation it does not.errors is for problems with the document — a syntax error, an unknown field,
a type mismatch. A mutation the server understood and then declined produces
none of that. It returns HTTP 200, no errors, and the refusal inside the
payload, one level below the operation name:
Envelope are
paths, and a path may contain
* to stand for whichever operation the tool called:
linear_envelope.py
errors_field takes one path or several, so both places an API reports failure
are covered by one declaration. A wildcard matches exactly one level — it is not
a recursive search — and matching nothing is not a failure, so a query with no
success and no userErrors passes through untouched.
The message names the resolved path, which is the useful half: data.issueCreate. success is false says which write was declined, without the envelope knowing
anything about Linear.
Why this is a declaration and not a response handler
The same check written into each mutation’sresponse_handler passes the same
tests. It is still wrong, for one reason: it is opt-in per tool. Add a mutation
in a hurry, forget the handler, and that tool reports a failed write as a
success — silently, in production, on the operation that changes data.
This is the third time this project has met that shape. Slack’s ok:false was
per-tool before it became an Envelope; API-key guarding was per-tool
build_request=_guard(...) before it moved into the runtime. Both are now
declarations, and both have a test named for the tool nobody remembered to
update. So does this: