You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
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.
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 server — if (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
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).
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.
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.
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.
Describe the bug
The protocol has an explicit marker for "this answer is a failure" — the
X-Server-Function-Errortag, which RFC 10 calls an error tag. The client does not trust it alone. It also treats the HTTP status as a failure signal: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.
The two halves of that trade produce one inconsistency each:
throw new Error("boom")"Internal Server Error"return respond({ retryIn: 30 }, { status: 500 })30return respond({ reason: "gone" }, { status: 404 }){ reason: "gone" }throw respond({ reason: "gone" }, { status: 404 })"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
500in place of404, and there the author's return is delivered as a rejection — not because anything failed, but because500is 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
repro.mjs:Output on 2.0.0-rc.4:
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 200and puts the outcome in agrpc-statustrailer ("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 separategrpc_statsfilter and asuppress_grpc_request_failure_code_statsswitch 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:
res.statusCode = 500and serialize the rejection into the payload — a real 500 and a structured error.BAD_REQUEST→ 400,INTERNAL_SERVER_ERROR→ 500, …), overridable per error viaerror.data.httpStatus.{ data } | { error }, and the status is consulted only for a response that did not come from its own server —if (res.status >= 400 && !res.headers.has("X-Remix-Response")), the comment citing "a 429 error returned from a CDN". Consequentlydata(payload, { status: 500 })ships a 500 and still resolves as a value.if (result instanceof Error) { throw result }), sosetResponseStatus(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
200on a failure that is known before a single byte is written.Options
failed = response.headers.has(ERROR_HEADER), withstatus >= 500kept 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. Thenreturn respond(v, { status: 500 })resolves like every other returned value, andthrowstill rejects. Small, and it is a prerequisite for (2).500(a thrown envelope keeps the author's status, as today), so intermediaries and monitoring see what happened; a failure discovered mid-stream keeps200plus 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.statuson 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/webnextRelated
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.