Skip to content

Failure is classified by HTTP status instead of the protocol's own error tag #3097

Description

@frenzzy

Describe the bug

The protocol has an explicit marker for "this answer is a failure" — the X-Server-Function-Error tag, which RFC 10 calls an error tag. The client does not trust it alone. It also treats the HTTP status as a failure signal:

// packages/web/server-functions/src/client.ts
const failed = response.headers.has(ERROR_HEADER) || response.status >= 500;

And because the status is overloaded as that signal, the server cannot use it truthfully: a thrown application error is answered with 200 OK plus the tag.

// packages/web/server-functions/src/server.ts
headers.set(ERROR_HEADER, encodeErrorHeaderValue(error));
return encodeResult(safe, headers, 200, codec, request.signal);

The two halves of that trade produce one inconsistency each:

function wire status what the caller gets
throw new Error("boom") 200 throws "Internal Server Error"
return respond({ retryIn: 30 }, { status: 500 }) 500 throws 30
return respond({ reason: "gone" }, { status: 404 }) 404 resolves { reason: "gone" }
throw respond({ reason: "gone" }, { status: 404 }) 404 throws "gone"

Every application error is a 200 on the wire. Whatever sits between the app and the user — a CDN, a load balancer, an APM agent, a log-based alert — sees a successful response. The failure is legible only to a client that knows to read a custom header.

Return and throw stop meaning different things at 5xx. The bottom two rows are the contract working: the author picks the plane, the status rides along, the value survives both ways. The second row is the same code with 500 in place of 404, and there the author's return is delivered as a rejection — not because anything failed, but because 500 is hardcoded as failure on the client. An author cannot answer "degraded, here is a partial payload and a retry hint" with the status that says so.

Steps to reproduce

mkdir sf-failure && cd sf-failure && npm init -y
npm i @solidjs/web@2.0.0-rc.4
node repro.mjs

repro.mjs:

import { AsyncLocalStorage } from "node:async_hooks";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const srv = await import("@solidjs/web/server-functions/server");
const cli = await import("@solidjs/web/server-functions/client");
const { respond } = await import("@solidjs/web");

srv.registerServerFunction("plain-throw", () => { throw new Error("boom"); });
srv.registerServerFunction("return-500", () => respond({ retryIn: 30 }, { status: 500 }));
srv.registerServerFunction("return-404", () => respond({ reason: "gone" }, { status: 404 }));
srv.registerServerFunction("throw-404", () => { throw respond({ reason: "gone" }, { status: 404 }); });

globalThis.fetch = (address, init) => {
  const request = new Request(new URL(address.toString(), "http://localhost"), init);
  request.headers.set("Sec-Fetch-Site", "same-origin");
  return srv.handleServerFunctionRequest(request);
};

const wire = async id =>
  (await srv.handleServerFunctionRequest(new Request(`http://localhost/_server/${id}`, {
    method: "POST", body: "[]",
    headers: { "Sec-Fetch-Site": "same-origin", "X-Server-Function-Instance": "i" } }))).status;

const seen = async id => {
  try { return "resolved " + JSON.stringify(await cli.createServerReference(id)()); }
  catch (error) { return "THREW    " + JSON.stringify(error?.reason ?? error?.retryIn ?? error?.message); }
};

console.log("function     | wire | client");
for (const id of ["plain-throw", "return-500", "return-404", "throw-404"]) {
  console.log(id.padEnd(12), "|", String(await wire(id)).padStart(4), "|", await seen(id));
}

Output on 2.0.0-rc.4:

function     | wire | client
plain-throw  |  200 | THREW    "Internal Server Error"
return-500   |  500 | THREW    30
return-404   |  404 | resolved {"reason":"gone"}
throw-404    |  404 | THREW    "gone"

Expected behavior

The error tag decides whether a call failed. The status describes the response to everyone else — and stays the author's to choose.

Prior art

Answering 200 for an application error has one famous precedent — and it is the one that documents the cost. gRPC-over-HTTP/2 sends :status 200 and puts the outcome in a grpc-status trailer ("Status must be sent in Trailers even if the status code is OK"). The spec names the consequence itself, while justifying an unrelated rule: "This will prevent other HTTP/2 clients from interpreting a gRPC error response, which uses status 200 (OK), as successful" (PROTOCOL-HTTP2.md). The infrastructure bill is visible in Envoy, which ships a separate grpc_stats filter and a suppress_grpc_request_failure_code_stats switch because "not all failed gRPC requests charge HTTP status code metrics" (router.proto).

Connect made the opposite choice for unary calls: "Errors are sent with a non-200 HTTP-Status", under a published code-to-status table (invalid_argument → 400, unavailable → 503, internal → 500), so that responses "have meaningful HTTP status codes" (protocol).

Closer to home, no peer uses the status as the failure signal for its own responses:

  • Next.js server actions answer a thrown error with res.statusCode = 500 and serialize the rejection into the payload — a real 500 and a structured error.
  • tRPC derives the status from the error code (BAD_REQUEST → 400, INTERNAL_SERVER_ERROR → 500, …), overridable per error via error.data.httpStatus.
  • React Router decides on the envelope: the wire type is { data } | { error }, and the status is consulted only for a response that did not come from its own serverif (res.status >= 400 && !res.headers.has("X-Remix-Response")), the comment citing "a 429 error returned from a CDN". Consequently data(payload, { status: 500 }) ships a 500 and still resolves as a value.
  • TanStack Start likewise decides on the payload (if (result instanceof Error) { throw result }), so setResponseStatus(500) does not convert a returned value into a rejection.

React Router's rule is option (1) almost verbatim — and half of it is already here: #3088 made status-based failure conditional on the response not carrying this protocol's own body-format header, which is the same "if it isn't ours, judge it by status" fallback.

The honest case for 200-plus-a-marker is streaming, and it applies to this runtime too. Once the body has started, the status line is spent, so a failure discovered mid-stream has nowhere else to go. Connect concedes exactly this split — "Streaming responses always have an HTTP status of 200 OK … with any errors sent in the last portion of the body" — and calls the result "effectively two protocols… This isn't intellectually satisfying… In practice, we've found the loss of purity well worth it."

That is a strong argument for the tag existing. It is not an argument for the client preferring the status over the tag, nor for stamping 200 on a failure that is known before a single byte is written.

Options

  1. Make the tag authoritative on the client, and let the status stop meaning failure: failed = response.headers.has(ERROR_HEADER), with status >= 500 kept only as the fallback for a response that has no body format at all — a proxy's own error, which fix(web): fail a call on a response the runtime did not write #3088 already separates from an application answer. Then return respond(v, { status: 500 }) resolves like every other returned value, and throw still rejects. Small, and it is a prerequisite for (2).
  2. Answer errors with a real status where there still is one. A thrown error becomes 500 (a thrown envelope keeps the author's status, as today), so intermediaries and monitoring see what happened; a failure discovered mid-stream keeps 200 plus the tag, because by then the status line is gone. Only safe once (1) lands — otherwise the client's own rule reads the new status as a second, conflicting signal, which is why these two belong in one issue.
  3. Keep 200 and document it. Defensible — the tag is unambiguous, and 200 keeps intermediaries from mangling an error body. It leaves observability where it is: a service whose errors are invisible to anything but its own client.
  4. Let the author opt in, e.g. a status on the sanitized error path. Configurable where a default would do, and two apps then disagree on what an error looks like on the wire.

(1) then (2). (1) alone is worth doing regardless: it removes a hardcoded status from a decision that the protocol already carries explicitly.

Environment

@solidjs/web 2.0.0-rc.4 (published), and next
Node v24.19.0
OS macOS (darwin 25.6.0)

Related

Builds directly on #3088, which established that a response without this protocol's body-format header is not ours to interpret — option (1) is the same idea applied to the responses that are ours. #3095 and #3096 are the other two places an author-chosen status does not survive.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions