[readonly] markdown buffer
The API Returned 200. The Operation Still Failed.
The request returned 200 OK.
The order was not placed.
That is not necessarily a contradiction. It means somebody treated transport success as proof of a business outcome.
HTTP status, application result and business result describe different layers. Reliable clients keep them separate.
HTTP can succeed while the request is rejected
Consider an API response:
{
"status": "rejected",
"reason": "credit_limit_exceeded"
}
The server received the request, understood it and returned a valid response. Whether 200 is the best status is an API-design choice, but the client cannot infer order placed from response.ok.
The body contains the outcome that matters.
GraphQL makes this especially visible: a response can arrive successfully with both data and errors. Batch APIs may successfully process the request while rejecting several individual records.
The network worked. The work did not fully succeed.
Acceptance is not completion
Asynchronous APIs add another state.
202 Accepted means the server accepted responsibility for attempting the work. It does not mean the export finished, the email was delivered or the payment settled.
The real lifecycle may be:
accepted → running → succeeded
↘ failed
A client that displays “Complete” after acceptance is inventing an outcome.
It needs a completion signal: polling a status resource, receiving a webhook, consuming an event or observing the resulting state.
- 01accepted12:00:00
- 02processing12:00:03
- 03rejected12:00:08
Model outcomes explicitly
Do not make callers reconstruct business meaning from a mixture of booleans, nullable fields and free-text messages.
Use a result that cannot claim incompatible states:
type PlaceOrderResult =
| { status: "placed"; orderId: string }
| { status: "rejected"; reason: RejectionReason }
| { status: "pending"; operationId: string };
The caller must handle the outcome it received:
const response = await fetch("/orders", request);
if (!response.ok) {
throw await transportError(response);
}
const result: PlaceOrderResult = await response.json();
switch (result.status) {
case "placed":
return showConfirmation(result.orderId);
case "rejected":
return showRejection(result.reason);
case "pending":
return watchOperation(result.operationId);
}
In real code, the response still needs runtime validation. A TypeScript annotation does not prove that an external payload matches the type.
Monitor the outcome users care about
An API dashboard showing 99.99% successful HTTP responses can remain green while every accepted job fails five minutes later.
Measure both:
- transport availability and latency
- final business outcomes and their latency
For asynchronous work, connect the original request, operation identifier, attempts and terminal result. Otherwise the initial response and eventual failure live in different systems with no reliable path between them.
Name the layer
“The call succeeded” is too vague.
Say whether the connection succeeded, the server accepted the request, the application processed it or the required business outcome completed.
Each can be true while the next is false.
HTTP status tells you something important. It just does not tell you everything.