Two requests reach the same JSON application.
The first lacks authentication. The server returns HTTP 200 with this body:
{
"status": 0,
"error": {
"code": 401,
"message": "Unauthenticated"
}
}The second is authenticated but lacks permission to browse a collection. It also returns HTTP 200, this time with an empty array:
[]Regression tests pin both behaviors. Neither can be interpreted from the status code alone, and the empty array is worse: it is indistinguishable from an authorized search which genuinely found nothing.
HTTP and the application answer different questions
RFC 9110 defines a 2xx response as a request successfully received, understood, and accepted. For a POST, its content represents the status or result of the action.
An application can still put another protocol inside that content. In the compatibility API, HTTP answers whether the exchange completed while the JSON envelope carries a second success marker.
That gives a client two gates:
transport gate:
did an HTTP response arrive in the accepted status range?
application gate:
does the decoded body say the requested business action was accepted?A transport helper such as throw() can enforce the first gate. It cannot
infer the second. Retained clients reflect that distinction: they reject HTTP
failure first, decode JSON, then inspect the body status. Calling both outcomes
“success” just because the status code says 200 erases the exact decision a
caller needs.
Model two outcomes instead of one boolean
A useful decoder returns a discriminated result:
type Result<T> =
| { kind: 'transport-failure'; status: number }
| { kind: 'application-rejection'; code: string; message: string }
| { kind: 'accepted'; value: T }The transport branch owns connection and HTTP semantics. The rejection branch owns the remote application’s declared business result. The accepted branch contains data whose shape has been validated.
This is more useful than pretending HTTP 200 settles the matter:
const ok = response.status === 200Blindly checking body.status is not much better because an HTML gateway error,
malformed JSON response, or ordinary 404 may not have that field. The decoder
must establish the outer protocol before trusting the inner one.
The public fixture for this post evaluates four synthetic responses:
HTTP 503 + error body -> transport-failure
HTTP 200 + status 0 -> application-rejection
HTTP 200 + empty array -> ambiguous-success
HTTP 200 + status 1 + data -> acceptedThe fourth category, ambiguous-success, is deliberate. A decoder cannot
manufacture information the server chose not to send.
An empty collection can hide authorization
Returning an empty collection for a forbidden search is sometimes presented as harmless information hiding. The response does not reveal whether a resource exists, and an old interface may already depend on the shape.
The cost is semantic collapse:
authorized, zero matches -> []
forbidden -> []No clever client-side technique can recover the difference. The client cannot decide whether to show “nothing found,” request a different permission, record a security event, or stop retrying because the server threw away the deciding fact.
There are cases where deliberately hiding existence is part of the security contract. HTTP already permits a server to use 404 rather than 403 for that purpose. Even then, the ambiguity should be chosen, documented, and tested. An empty success response makes the operation appear authorized, not merely undisclosed.
For a search feature where the user must know that access is unavailable, a
403 response or a typed rejection envelope is the more useful contract. Hiding
the problem behind [] makes the interface look simple by making every caller
guess.
Compatibility can make the awkward contract real
The conventional repair is straightforward:
missing authentication -> 401
insufficient authority -> 403
valid empty search -> 200 []
accepted operation -> 2xx with its resultPrefer that contract for a new API. It lets generic HTTP clients, proxies, metrics, caches, and tracing tools classify the response before understanding application JSON.
Changing an existing API is not automatically an improvement. A consumer may
currently treat every non-2xx response as an infrastructure outage. Another
may parse the status: 0 envelope to display a useful message. “Fixing” the
server alone can turn a known rejection into retries, alerts, or a generic
error.
The contract therefore has two possible migration paths.
If every consumer is owned and can move atomically, change the response status and client together. Preserve tests for both transport and application semantics.
If consumers move independently, introduce a version or capability signal. During the overlap, the client decoder may need to recognize both:
legacy:
HTTP 200 + rejection envelope
current:
HTTP 4xx + typed error bodyCompatibility is not an argument for keeping the legacy shape forever. It is a requirement to know who reads it and when the last old reader is actually gone, not when everyone assumes it probably is.
Error shape still matters with honest status codes
Using 401 or 403 does not finish the design. A client needs a stable, machine-readable body:
{
"error": {
"code": "permission_missing",
"message": "This account cannot browse the collection",
"correlation_id": "trace-demo-14"
}
}The status tells generic infrastructure which class of result occurred. The code lets the application choose a response. The message is for a human and should not become a brittle branching key. The correlation identifier joins the report to operational evidence.
Keep the code stable across wording and localization changes. Document whether the client may retry, whether different credentials could succeed, and whether the operation may already have produced a side effect. Those decisions cannot be derived from “non-200” either.
Test the matrix, not just the happy response
Contract tests should cover the Cartesian product which consumers actually interpret:
| HTTP outcome | Body outcome | Expected client result |
|---|---|---|
| failure | valid error | transport failure with decoded context |
| failure | malformed body | transport failure without application detail |
| success | rejection envelope | application rejection |
| success | accepted envelope | accepted value |
| success | malformed envelope | protocol violation |
| success | ambiguous empty body | explicitly documented ambiguity |
The regression tests pin unusual server behavior. They are valuable because they
stop a client author from assuming that 200, status: 1, and authorized access
are synonyms. They do not prove that the behavior is desirable or that a
third-party incident occurred.
For a new contract, align HTTP status with the operation’s result. For an old one, decode the protocol you actually have instead of the one you wish existed, then plan its migration. The client is finished only after both the transport and the application have answered.