Skip to content

A response the runtime did not produce resolves the call to undefined instead of failing #3087

Description

@frenzzy

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)

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