Skip to main content

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

An Envelope goes on the factory, beside the rest of the API’s contract:
slack_envelope.py
Every tool that factory builds now raises on a failed body, exactly as it would on a 4xx. There is no per-tool step. Two shapes cover almost everything: A success flagok_field must be truthy. For APIs that report a word rather than a bool, add ok_value:
An error collectionerrors_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”: 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

Response handlers are for context economy. Trimming a failed payload is meaningless, so the envelope raises first — which also means a handler never needs a defensive check at the top.

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.

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:
Every signal a runtime normally trusts says the write happened. So the field names on an 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’s response_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:
If a rule is true of the API rather than of one endpoint, it belongs on the factory. A response handler is for shaping what came back, not for deciding whether it succeeded.