Skip to content

fix(web): fail a call on a response the runtime did not write - #3088

Merged
ryansolid merged 4 commits into
solidjs:nextfrom
frenzzy:sf-foreign-response
Aug 28, 2026
Merged

fix(web): fail a call on a response the runtime did not write#3088
ryansolid merged 4 commits into
solidjs:nextfrom
frenzzy:sf-foreign-response

Conversation

@frenzzy

@frenzzy frenzzy commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Fixes #3087.

A call counted as failed only when the response carried the runtime's error header or a 5xx. Everything else non-2xx went to the decode path, where a login page or an empty 405 yields nothing — so the call resolved to undefined, reading as "the function returned nothing" where the request had not reached it. That set includes the handler's own refusals:

answered by status was now
method allowlist (undeclared read) 405 undefined throws, error.status === 405
origin gate (cross-site) 403 undefined throws
malformed ?args= 400 undefined throws
a route the handler isn't mounted on 404 undefined throws
an auth interstitial 401 undefined throws
a CDN or WAF block 403 undefined throws

The rule

A response at 400 or above with no body format is not a result. It fails the call with the status on the error — undecoded, because the body belongs to whoever answered, and before the passthrough redirects and revalidation use, because a refusal can carry a Location of its own and the passthrough would hand it back as control flow.

One marker is enough: every path that encodes a response stamps the body format, thrown results included. BodyFormat.Void closes the one that had none — a function that returned nothing — so all three of these still resolve:

respond({ field: "required" }, { status: 400 });   // → { field: "required" }
respond(undefined, { status: 400 });                // → undefined
return new Response(null, { status: 404 });         // → null

The middle one used to disagree with the last, which reaches null through the JSON path.

Limits

A 2xx is not judged. fetch follows redirects, so an auth interstitial usually arrives as its login page at 200, and a missing route on a static host as an SPA index.html at 200 — indistinguishable from a void result by header alone. Confirmed in a browser: redirect: "follow" gives 200 basic, redirect: "manual" an opaqueredirect at status: 0.

Redirects stay with the passthrough. A 3xx only reaches the transport where something opted out of following one, and treating it as a refusal broke a verbatim X-Content-Raw response carrying a Location — the documented way to answer a call with a response of your own.

Two neighbouring gaps stay open, both older than this change and worth their own issues if you agree they matter: live()'s retry classifier reads 400–499 as definite, so a foreign 408 or 429 now ends a source permanently where it used to close silently; and decodeResponse still answers undefined for the responses the transport now throws on.

Notes

The repro in #3087 runs against the published 2.0.0-rc.3, so the old behaviour is reproducible without a local build.

Checked across every shape the runtime can answer with: the only non-2xx reaching the wire without a format header is an X-Content-Raw passthrough, which an integration's responseHandler claims before the check.

@changeset-bot

changeset-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a3dfb42

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
solid-js Patch
@solidjs/compiler Patch
@solidjs/universal Patch
@solidjs/signals Patch
@solidjs/diagnostics Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed-hq

codspeed-hq Bot commented Aug 28, 2026

Copy link
Copy Markdown

Merging this PR will regress 2 benchmarks

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
❌ 2 regressed benchmarks
✅ 133 untouched benchmarks
⏩ 132 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
omit 235.5 µs 319.7 µs -26.36%
merge 267.6 µs 362.8 µs -26.24%
merge 138 µs 71.6 µs +92.82%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing frenzzy:sf-foreign-response (a3dfb42) with next (0043643)

Open in CodSpeed

Footnotes

  1. 132 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@frenzzy
frenzzy marked this pull request as draft August 28, 2026 07:12

@ryansolid ryansolid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against the runtime's encode paths — the analysis holds, and this catches a real gap. The 405 shape in your table is one I hit myself building the invoke test suite: GET against an undeclared method decoded to undefined and cost real diagnosis time that error.status === 405 would have saved.

Verified the load-bearing claim from the source: the format header is genuinely the universal discriminator. All seven natural encodings stamp it in getHeadersAndBody, the codec stamps at the serialized-stream response, the JSON fast path stamps, and — the corner I went looking for — a function returning a raw Response over the runtime wire doesn't bypass it either: the handler merges status/headers and re-encodes the body through encodeResult, so the caller's non-2xx raw Response arrives stamped and still resolves as a value. The only unstamped non-2xx answers really are the handler's own gate refusals and foreign infrastructure, which is exactly the set that should fail.

Two corners worth a line in the spec header, neither blocking:

  • respond(undefined, { status: 4xx }) — you called this out already; throwing is strictly more informative than resolving undefined with a status the caller can't see, since the transport never exposes the response on the success path.
  • A bare X-Content-Raw non-2xx (verbatim passthrough, no single-flight header) now fails at a runtime-less call site. Integrations are unaffected — the responseHandler seam sees the response before this check — and a transport that can't decode a payload reporting failure over minting undefined is the right default, but it is the one runtime-produced shape the new clause catches.

The CodSpeed failure is a fork artifact (it fails identically on your other PR and passes on next) — not yours, and a one-clause change on the fetch path has no benchmark surface.

Ready to go from my side once you mark it ready for review.

@frenzzy

frenzzy commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Your review landed on the version before a rework, so the corner you approved has moved.

respond(undefined, { status: 4xx }) no longer throws. My premise was wrong: encodeResult's void branch was the one encode path with no format header, so a void result with a status looked exactly like a bare refusal — and it disagreed with new Response(null, { status: 404 }), which resolves to null. BodyFormat.Void marks that branch now, so both resolve and the invariant you verified holds without an exception.

Also since then:

  • The check ran after the Location / X-Revalidate passthrough, so a foreign 403 + Location still resolved. It runs before it now.
  • A refusal with application/x-www-form-urlencoded threw a URLSearchParams with no .status, which live() reads as retryable. Foreign responses are not decoded at all now.
  • I had widened the check to 300 and up, on the reasoning that an SSO interstitial answers with a Location. A browser never surfaces a 3xx here — fetch follows redirects, manual gives an opaqueredirect at status 0 — and it broke a raw X-Content-Raw passthrough carrying a Location. Back to 400 and up.
  • Testing for the error header as well as the format turned out to ask the same question twice: every path that tags a thrown result encodes it too. One marker now.

Your X-Content-Raw corner is pinned both ways: the non-2xx raw passthrough failing, and responseHandler claiming a response before the check.

…s#3087)

Only the protocol's error header and a 5xx counted as failure, so every other
non-2xx was decoded as a result — and decoding a login page, or an empty 405,
yields nothing. The call resolved to `undefined`, reading as "the function
returned nothing" where the request had not reached it.

A response at 300 or above carrying neither the error header nor a body format
now fails the call with the status on the error. Undecoded, because its body
is someone else's; and before the passthrough that control flow uses, because
a foreign refusal carries a `Location` of its own often enough — an SSO
interstitial is exactly that — that the passthrough would have swallowed it.
The runtime never puts a redirect on the wire as a 3xx, so nothing of its own
falls in the range.

`BodyFormat.Void` marks the one response the runtime encodes without a format
to carry, so a void result with a status stays a result, matching what the
equivalent bare `Response` already did.
@frenzzy
frenzzy force-pushed the sf-foreign-response branch from 3ac88c8 to d35b914 Compare August 28, 2026 11:39
The check covered 300 and up, on the reasoning that an SSO interstitial
answers a refusal with a `Location` of its own. Driving a browser against a
real origin says otherwise: `fetch` follows redirects, so the interstitial
arrives as its login page at 200, and `redirect: "manual"` yields an
opaqueredirect at status 0 — neither is a 3xx by the time the transport sees
it. The clause only ever fired server-side, in tests, and on synthesized
responses.

What it cost there was real: a verbatim `X-Content-Raw` passthrough carrying a
`Location` — the documented escape hatch for answering a call with a response
of one's own — stopped reaching the caller, and a `live()` source facing a
foreign 3xx reconnected forever instead of ending, because the retry
classifier reads 4xx as definite and everything else as transient.

Back to 400 and up, with the redirect passthrough pinned.
@frenzzy frenzzy changed the title fix(web): fail a call on a response the runtime did not produce fix(web): fail a call on a response the runtime did not write Aug 28, 2026
frenzzy and others added 2 commits August 28, 2026 20:17
Reverting each part of the rule one at a time found two that no test could
watch, because no input reaches them.

The error header never appears without a body format: every path that tags a
thrown result encodes it too, so testing for both markers asked the same
question twice. One marker, and the invariant it rests on, is stated where the
check is.

The explicit decode case for a void body answered `undefined`, which is what
the fallthrough beneath it already answered. The runtime cannot produce a void
response carrying a body, so nothing else could reach the case either.

@ryansolid ryansolid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Green on the rebased base. The earlier CodSpeed flags were cross-environment noise on signals store-utility benchmarks this transport change cannot reach.

@ryansolid
ryansolid merged commit c07edcb into solidjs:next Aug 28, 2026
6 checks passed
@frenzzy
frenzzy deleted the sf-foreign-response branch August 29, 2026 01:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants