Describe the bug
The client transport treats a response as a failure only when it carries the runtime's own error header or a 5xx status:
// packages/web/server-functions/src/client.ts
const failed = response.headers.has(ERROR_HEADER) || response.status >= 500;
So a non-2xx answer that the runtime did not produce is decoded as if it were a result. Its body is not an encoded payload, the decode yields nothing, and the call resolves to undefined — no throw, no console output, no status anywhere the caller can see. const story = await getStory(1) reads as "the function returned nothing" rather than "the request never reached it".
The set of responses this covers is wider than it looks. It includes the handler's own refusals, which carry no error header:
| answered by |
status |
call resolves to |
handler's method allowlist (GET to a function that never declared GET) |
405 |
undefined |
| handler's origin gate (cross-site) |
403 |
undefined |
| handler's malformed-arguments answer |
400 |
undefined |
| a route the handler is not mounted on |
404 |
undefined |
| an auth proxy's login page |
401 |
undefined |
| a CDN or WAF block |
403 |
undefined |
The 5xx clause is documented as a defensive fallback — "Proxies may omit the protocol error header on 5xx responses." The same reasoning applies to everything above: those responses are equally not the runtime's, and equally not a result.
Steps to reproduce
mkdir sf-4xx && cd sf-4xx && npm init -y
npm i @solidjs/web@2.0.0-rc.3
node repro.mjs
repro.mjs:
import { AsyncLocalStorage } from "node:async_hooks";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const { createServerReference } = await import("@solidjs/web/server-functions/client");
const { registerServerFunction } = await import("@solidjs/web/server-functions/server");
registerServerFunction("getStory", async () => ({ title: "Async Solid" }));
const answers = {
"404 (handler not mounted there)": new Response("<h1>Not found</h1>", {
status: 404,
headers: { "content-type": "text/html" }
}),
"401 (auth interstitial)": new Response("<html>login</html>", {
status: 401,
headers: { "content-type": "text/html" }
}),
"403 (CDN/WAF block)": new Response(null, { status: 403 })
};
for (const [label, response] of Object.entries(answers)) {
globalThis.fetch = async () => response.clone();
let outcome;
try {
outcome = "resolved " + JSON.stringify(await createServerReference("getStory")());
} catch (error) {
outcome = "threw " + String(error).slice(0, 50);
}
console.log(label.padEnd(34), "->", outcome);
}
Output:
404 (handler not mounted there) -> resolved undefined
401 (auth interstitial) -> resolved undefined
403 (CDN/WAF block) -> resolved undefined
The handler-produced 400/403/405 behave the same; I reproduced those against a build of next (12fa7295) rather than the published rc, since two of them only exist there.
Expected behavior
A response the runtime did not encode should fail the call, with the status on the error, the way a 5xx already does.
The two can be told apart without a new header: every encoding path stamps X-Server-Function-Format — natural encodings, the JSON fast path and the codec alike (getHeadersAndBody / encodeResult / serializedResponse) — so a non-2xx carrying neither that nor the error header is one nothing in the runtime wrote. That keeps respond(value, { status: 400 }) resolving as a value, which is the pattern the validation guidance in RFC 10 points at.
I have a patch for this and will open it as a PR.
Environment
|
|
@solidjs/web |
2.0.0-rc.3 (published), and next @ 12fa7295 |
| Node |
v24.19.0 |
| OS |
macOS (darwin 25.6.0) |
Describe the bug
The client transport treats a response as a failure only when it carries the runtime's own error header or a 5xx status:
So a non-2xx answer that the runtime did not produce is decoded as if it were a result. Its body is not an encoded payload, the decode yields nothing, and the call resolves to
undefined— no throw, no console output, no status anywhere the caller can see.const story = await getStory(1)reads as "the function returned nothing" rather than "the request never reached it".The set of responses this covers is wider than it looks. It includes the handler's own refusals, which carry no error header:
GET)undefinedundefinedundefinedundefinedundefinedundefinedThe 5xx clause is documented as a defensive fallback — "Proxies may omit the protocol error header on 5xx responses." The same reasoning applies to everything above: those responses are equally not the runtime's, and equally not a result.
Steps to reproduce
repro.mjs:Output:
The handler-produced 400/403/405 behave the same; I reproduced those against a build of
next(12fa7295) rather than the published rc, since two of them only exist there.Expected behavior
A response the runtime did not encode should fail the call, with the status on the error, the way a 5xx already does.
The two can be told apart without a new header: every encoding path stamps
X-Server-Function-Format— natural encodings, the JSON fast path and the codec alike (getHeadersAndBody/encodeResult/serializedResponse) — so a non-2xx carrying neither that nor the error header is one nothing in the runtime wrote. That keepsrespond(value, { status: 400 })resolving as a value, which is the pattern the validation guidance in RFC 10 points at.I have a patch for this and will open it as a PR.
Environment
@solidjs/webnext@12fa7295