Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 36 additions & 17 deletions runner/apps/authoring/src/fetchFailure.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/**
* Is `error` the browser's opaque transport failure — the request never completed,
* so there is no status, no body, and nothing about our host in it?
* so there is no status and no body? (The message itself may still name our host —
* see the parenthesised-suffix note below — but nothing else about the response
* does.)
*
* Sentry DEMOS-2X / DEMOS-2Y. Deliberately shape-based and NOT gated on
* `navigator.onLine`: unlike the Tier-1 compiler-asset branch in tier1Report.ts,
Expand All @@ -17,34 +19,51 @@
* `--experimental-strip-types`, so `pipeline/fetch-failure.test.mjs` can pin the
* decision without pulling in `@sentry/react` or `import.meta.env`.
*
* Matched on a message *substring*, not on the Sentry issue title. DEMOS-2X's title
* is `TypeError: Failed to fetch (demos.handsontable.com)`, but the host suffix is
* appended by the Sentry SDK's own fetch instrumentation
* (`@sentry/core/build/esm/instrument/fetch.js`) — the `error.message` that actually
* reaches this `.catch` is just `Failed to fetch`. A classifier written against the
* title would never fire in production. Verified by observation (Step 0a, this
* plan): `route.abort("failed")` under Chromium raises exactly
* `TypeError: Failed to fetch`.
* Matched on `error.message` itself (via the anchored regex below), not on the
* Sentry issue title. DEMOS-2X's title
* is `TypeError: Failed to fetch (demos.handsontable.com)`, and — contrary to this
* module's original assumption — that host suffix is NOT stripped by the time
* `error.message` reaches this `.catch`: DEMOS-2X's latest events (2026-09-08,
* releases `930f6a52` and `e32cdcdf`, both after the `^failed to fetch$` gate landed
* in commit 590cb58b2 / PR #274) still carry `Failed to fetch (demos.handsontable.com)`
* verbatim, and that gate — the only caller of `fetchVersions` is gated on it — has
* never once matched it, so the "demotion" it was meant to apply has never taken
* effect in production. The shape below now accepts one optional parenthesised
* suffix so the gate actually reaches the population it was written for. Verified by
* observation (Step 0a, this plan): `route.abort("failed")` under Chromium raises
* exactly `TypeError: Failed to fetch`, i.e. without a suffix — so both the bare and
* suffixed forms are real, observed wordings and both must match.
*
* One named regex per engine wording — in the style of `isPreviewPortUnreachable`
* (`workers/api/src/preview-boot.ts`) — rather than one fused pattern, so a wording
* this table does not cover shows up as a new Sentry event instead of being folded
* in silently.
*
* The Chromium row is anchored (`^...$`) rather than a loose substring match. Chromium
* also raises `TypeError: "Failed to fetch dynamically imported module: <url>"` for a
* deploy-rotated chunk served under SPA fallback — a real host defect this codebase
* already treats as one (`packages/runtime/src/transpile.ts`, Sentry DEMOS-15 /
* DEV-2569). An unanchored `/failed to fetch/i` matches that message too, silencing a
* defect class it has no business touching; the bare transport failure this module
* exists for is always the whole message, never a prefix of a longer one. Firefox and
* The Chromium row is anchored (`^...$`) rather than a loose substring match, with
* the host suffix carved out as a single optional `(?: \([^)]*\))?` group immediately
* before the `$` — so the suffix, when present, must be the very end of the message.
* Chromium also raises `TypeError: "Failed to fetch dynamically imported module:
* <url>"` for a deploy-rotated chunk served under SPA fallback — a real host defect
* this codebase already treats as one (`packages/runtime/src/transpile.ts`, Sentry
* DEMOS-15 / DEV-2569). An unanchored `/failed to fetch/i` matches that message too,
* silencing a defect class it has no business touching; the anchor plus the narrow
* optional group still excludes it, because that wording is not "Failed to fetch"
* plus a trailing `(...)` — it has different words after the colon. The bare
* transport failure this module exists for is always the whole message or the whole
* message plus a parenthesised host, never a prefix of a longer one. Firefox and
* Safari stay loose: Firefox's real wording carries a trailing period
* (`NetworkError when attempting to fetch resource.`), so a `$`-anchored version would
* break the one wording it exists to match, and no over-match has been demonstrated
* for either engine.
*/
const OPAQUE_TRANSPORT_MESSAGES = [
/^failed to fetch$/i, // Chrome/Chromium/Edge — verified against `route.abort("failed")` (Step 0a)
// Chrome/Chromium/Edge — verified against `route.abort("failed")` (Step 0a) for the
// bare form; the optional `(host)` suffix is the actual production wording seen in
// Sentry DEMOS-2X (e.g. `Failed to fetch (demos.handsontable.com)`, releases
// `930f6a52` / `e32cdcdf`, 2026-09-08). Nothing in this codebase appends it (grepped);
// which layer does — the browser itself vs. the Sentry SDK's fetch instrumentation —
// is not verified.
/^failed to fetch(?: \([^)]*\))?$/i,
/networkerror when attempting to fetch resource/i, // Firefox
/load failed/i, // Safari
];
Expand Down
25 changes: 24 additions & 1 deletion runner/pipeline/fetch-failure.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,24 @@ import { isOpaqueNetworkFailure } from "../apps/authoring/src/fetchFailure.ts";
// Sentry DEMOS-2X / DEMOS-2Y. `fetchVersions` and `loadStarterExample` both raise
// `TypeError: Failed to fetch` when the tab's own network drops mid-request — not a
// shape our host can produce at these same-origin callsites (see fetchFailure.ts).
// The four `false` rows below are the point: they pin that a host-side failure (a
// The `false` rows below are the point: they pin that a host-side failure (a
// Worker 5xx, a 404, or Workers-Assets' SPA-fallback HTML tripping res.json()) keeps
// reporting, so this demotion narrows to the visitor-network population only.

// Fix-prover (DEV-2859): the real production wording, verbatim from Sentry DEMOS-2X's
// latest events (2026-09-08, releases `930f6a52` and `e32cdcdf` — both after the
// `^failed to fetch$` gate landed in 590cb58b2 / PR #274). This is the string that
// proves the gate never actually fired: with the anchored-no-suffix pattern reverted,
// this assertion goes red.
test("classifies the real production wording with the host suffix (Sentry DEMOS-2X, DEV-2859)", () => {
assert.equal(
isOpaqueNetworkFailure(new TypeError("Failed to fetch (demos.handsontable.com)")),
true,
);
});

// Regression guard: the bare wording (no host suffix) must keep matching too — this
// passes both before and after DEV-2859, it just must not regress.
test("classifies the observed Chrome wording (route.abort('failed'), Step 0a)", () => {
assert.equal(isOpaqueNetworkFailure(new TypeError("Failed to fetch")), true);
});
Expand Down Expand Up @@ -57,6 +71,15 @@ test("does not classify a deploy-rotated dynamic-import chunk failure (Sentry DE
);
});

// Anchor guard (DEV-2859): the parenthesised suffix must be the END of the message.
// Proves the trailing `$` still binds after the new optional group, not just the `^`.
test("does not classify a suffix that isn't at the end of the message", () => {
assert.equal(
isOpaqueNetworkFailure(new TypeError("Failed to fetch (demos.handsontable.com) extra")),
false,
);
});

test("does not classify a non-Error value", () => {
assert.equal(isOpaqueNetworkFailure("Failed to fetch"), false);
assert.equal(isOpaqueNetworkFailure(null), false);
Expand Down
Loading