fix(web): fail a call on a response the runtime did not write - #3088
Conversation
🦋 Changeset detectedLatest commit: a3dfb42 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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 |
Merging this PR will regress 2 benchmarks
|
| 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)
Footnotes
-
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. ↩
ryansolid
left a comment
There was a problem hiding this comment.
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 resolvingundefinedwith a status the caller can't see, since the transport never exposes the response on the success path.- A bare
X-Content-Rawnon-2xx (verbatim passthrough, no single-flight header) now fails at a runtime-less call site. Integrations are unaffected — theresponseHandlerseam sees the response before this check — and a transport that can't decode a payload reporting failure over mintingundefinedis 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.
a628701 to
762d177
Compare
762d177 to
3ac88c8
Compare
|
Your review landed on the version before a rework, so the corner you approved has moved.
Also since then:
Your |
…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.
3ac88c8 to
d35b914
Compare
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.
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
left a comment
There was a problem hiding this comment.
Green on the rebased base. The earlier CodSpeed flags were cross-environment noise on signals store-utility benchmarks this transport change cannot reach.
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:undefinederror.status === 405undefined?args=undefinedundefinedundefinedundefinedThe 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
Locationof 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.Voidcloses the one that had none — a function that returned nothing — so all three of these still resolve:The middle one used to disagree with the last, which reaches
nullthrough the JSON path.Limits
A 2xx is not judged.
fetchfollows redirects, so an auth interstitial usually arrives as its login page at 200, and a missing route on a static host as an SPAindex.htmlat 200 — indistinguishable from a void result by header alone. Confirmed in a browser:redirect: "follow"gives200 basic,redirect: "manual"an opaqueredirect atstatus: 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-Rawresponse carrying aLocation— 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 foreign408or429now ends a source permanently where it used to close silently; anddecodeResponsestill answersundefinedfor 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-Rawpassthrough, which an integration'sresponseHandlerclaims before the check.