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:
[]Both behaviors are pinned by retained tests. Neither can be interpreted from the status code alone. Worse, the empty array 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 retained 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” 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:
const ok = response.status === 200It is also more useful than blindly checking body.status, 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 in that fixture, ambiguous-success, is deliberate. A
decoder cannot manufacture information the server omitted.
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 client-side technique can recover the difference. It cannot decide whether to show “nothing found,” request a different permission, record a security event, or stop retrying.
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.
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. Switching 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 gone.
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 be the 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 retained 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 and plan its migration. The client is finished only after both the transport and the application have answered.