Managed IT · October 6, 2026 · intSignal Team

Your API Returned HTML and Your App Blamed the User

Share this article

A signup flow stopped working at the phone-verification step. The person entered a mobile number, pressed the button, and got: "Something went wrong. Please try again." They tried again. Same message. No text message ever arrived.

The real answer, visible only with a request trace, was this:

POST /api/signup/phone/send
HTTP/2 502
content-type: text/plain; charset=UTF-8
server: cloudflare

error code: 502

The API never answered. An edge proxy returned a plain-text error page because the origin handler threw. Our client turned that into advice to try again — advice that could not possibly work, because the thing being retried was down.

The bug is one line, and it is in almost every codebase

const res = await fetch(url, init);
const data = await res.json();      // ← throws on an HTML or text error page
return data;

Wrapped, as it usually is, in a try/catch that maps anything unexpected to a generic failure:

try {
  const res = await fetch(url, init);
  return await res.json();
} catch {
  return { ok: false, error: "network" };
}

Now trace what a 502 does. fetch resolves — a 502 is a successful HTTP exchange as far as fetch is concerned, which surprises people. res.json() tries to parse error code: 502 and throws a SyntaxError. The catch fires. The result is error: "network". And if, as in our case, "network" has no entry in the message map, the user-facing text falls through to a generic default.

So a server outage is presented identically to a typo. Three different failures — the network is down, the server is broken, the input is invalid — arrive at the same sentence.

Why this matters more than it looks

The person cannot act on a generic error, so they do the only thing available: retry. Each retry hits a broken endpoint, and on an endpoint with rate limiting they will eventually also get locked out — turning a server fault into a second, different error that is also not their fault.

Meanwhile nothing in your telemetry says "outage". The client reported a generic error. The server reported a 502 that may well be aggregated into an edge dashboard nobody is watching. The two never meet.

The fix: classify the non-JSON case by status

Separate "the API answered with something I can read" from "the API did not answer", and when it did not answer, use the status code to decide whose fault it is.

export async function postJson(path, body, fetchImpl = fetch) {
  try {
    const res = await fetchImpl(url(path), {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
      credentials: "omit",
    });

    const data = await res.json().catch(() => null);
    if (data && typeof data === "object") return data;

    // Not JSON at all — a proxy error page, typically. A 5xx is our fault and must say so.
    return {
      ok: false,
      error: res.status >= 500 ? "server_error" : "invalid_request",
      status: res.status,
    };
  } catch {
    // fetch itself rejected: DNS, TLS, offline, CORS.
    return { ok: false, error: "network" };
  }
}

Three things changed:

  • res.json() gets its own .catch(() => null) so a parse failure is handled where it happens, rather than being swallowed by the outer catch alongside genuine network errors.
  • The status decides the code. >= 500 is deliberate, and the boundary matters — see below.
  • The status is kept on the result so support can see what actually came back.

Then write messages that tell the truth

network: "We couldn't reach our servers. Check your connection and try again.",
server_error: "Something on our end is down right now — this isn't you. " +
              "Please try again in a few minutes, or contact us and we'll finish the setup with you.",

The second one does three jobs: it says the fault is ours, it stops the person hunting for a mistake they did not make, and it offers a route that works while the endpoint does not. For a signup flow that is the difference between a lost customer and a phone call.

Make sure neither code triggers a "start over" path. A server fault is transient; throwing away a part-finished application because the server hiccuped is a second bug.

Do the same for reads

Our status endpoint had the identical flaw, with worse consequences:

const res = await fetch(statusUrl);
return await res.json();      // a proxy error page here is read as a status object

This endpoint tells the UI which step to resume on after a payment redirect. If a 502 page parsed into anything truthy, the resume logic would route on garbage. Same fix: parse defensively, and return an explicit failure rather than a shape that will be destructured as though it were real.

const data = await res.json().catch(() => null);
return data && typeof data === "object"
  ? data
  : { ok: false, error: res.status >= 500 ? "server_error" : "invalid_request" };

Tests that pin it

The valuable test reproduces the exact production response:

const gateway = await postJson("/api/signup/phone/send", {}, async () => ({
  status: 502,
  json: async () => { throw new Error("Unexpected token 'e'"); },
}));

assert.equal(gateway.error, "server_error");
assert.equal(gateway.status, 502);

And one asserting the message does not blame the person:

assert.ok(!/check your details|start again/i.test(messageFor("server_error")));

Check the boundary explicitly — 500, 502, 503 and 504 on one side, 499 on the other. When we weakened >= 500 to > 500, every existing test still passed, because none of them used a bare 500. A plain 500 Internal Server Error would have been classified as a client mistake and the person told to check their details.

The wider rule

A response that is not JSON is not a failed parse — it is information. It tells you something answered that was not your API. Keeping the status code and routing on it costs five lines, and it changes what the person on the other end is told from an accusation into an explanation.

Share this article