XSCREENSAVER / 3D PIPES

[readonly] markdown buffer

The API Returned 200. The Operation Still Failed.

Jul 25, 2026 · 6 min read

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.

traceview://outcome
01
transportHTTP 200
02
applicationaccepted
03
businessorder rejected
A successful response can carry an accepted request whose final business outcome still fails.

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.

traceview://outcome-lifecycle
  1. 01accepted12:00:00
  2. 02processing12:00:03
  3. 03rejected12:00:08
client at 12:00:00 Order complete ✓ actual result at 12:00:08 Payment declined
The client reports success when the job is merely accepted. Eight seconds later, the actual business outcome is rejection.

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
traceview://outcome-monitoring
HTTP success 99.99% dashboard says healthy
orders completed 41.2% customers see failure
Transport monitoring remains green while fewer than half of the accepted orders reach the outcome customers care about.

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.