Throw FetchError with HTTP status from fetchJson#2748
Conversation
`fetch.ts` was throwing `new Error(res.statusText)` on non-2xx responses.
Over HTTP/2 (Cloudflare and most modern backends), `statusText` is empty
in Chrome, so every `fetchJson` failure surfaced as `Error` with no
message. Sentry groups by stack-trace fingerprint, so distinct failure
modes (rate-limit 429, bad request 400, header bloat 431, etc.) all
collapsed into a single issue ("No error message" — FREIGHTER-DMQ).
Replace the bare throw with a `FetchError` subclass that includes the
status code (and optionally response body, capped at 200 chars) in the
message. This:
- restores grouping fidelity in Sentry — separate issues per status code
- preserves the original status/body on the error object for callers
that want to inspect them
- keeps the existing catch + setError UX unchanged
Verified against `useScanTx`'s catch path in `blockaid.ts`, which
re-throws via `Sentry.captureException(err instanceof Error ? err : ...)`
— a `FetchError` is an `Error`, so capture behavior is unchanged but
event messages are now informative.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves observability for failed fetchJson calls by replacing message-less Error(res.statusText) throws (common under HTTP/2) with a dedicated FetchError that includes HTTP status context, improving Sentry grouping and triage for distinct failure modes.
Changes:
- Introduced a
FetchErrorsubclass that carriesstatus,statusText, and optionalbody, and formats a more informative error message. - Updated
fetchJsonto throwFetchErroron non-2xx responses and attempt to read the response body for added context. - Added Jest coverage for success and several non-2xx regression cases (including empty
statusText).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| extension/src/popup/helpers/fetch.ts | Adds FetchError and updates fetchJson to throw it with HTTP status context on non-OK responses. |
| extension/src/popup/helpers/tests/fetch.test.ts | Adds unit tests covering 2xx success and several error-status scenarios (incl. empty statusText regression). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const detail = body ? `: ${body.slice(0, 200)}` : ""; | ||
| super( | ||
| `Request failed with status ${status}${statusText ? ` (${statusText})` : ""}${detail}`, | ||
| ); |
| let body: string | undefined; | ||
| try { | ||
| body = await res.text(); | ||
| } catch { | ||
| // body unreadable — keep status-only message | ||
| } | ||
| throw new FetchError(res.status, res.statusText, body); |
|
Nice fix on the 1. The non-JSON content-type branch is still bare
|
I think we can ignore 4 here but the others are legit imo |
Review on the initial commit (stellar#2748) flagged adjacent error-reporting gaps in the same surface. This addresses them in one shot: - `FetchError` now carries url (sanitized — query string dropped), method, and a `kind` discriminator (`http-error` | `non-json`). Stable, status-coded message format for Sentry grouping. - Non-JSON content-type branch throws `FetchError` too (was the inverse grouping bug — body interpolated into `error.message`, fragmenting Sentry on every Cloudflare interstitial). - New `captureFetchError` helper unpacks `FetchError` into Sentry tags (`http.status`, `http.method`, `http.kind`) and a `setContext` block, so the new fields are filterable. Routing the body through `setContext` also runs Sentry's data scrubbers on it. - Bounded body reads: skip when Content-Length exceeds 4 KB, hard cap at 200 chars in the snippet, and emit `…[truncated]` marker so future readers can tell content was cut. - `cause` chaining on body-read failures via `super(msg, { cause })`. - Migrated `scanSite`, `scanAsset`, `scanAssetBulk` from raw `fetch` to `fetchJson` so they get the same FetchError + grouping treatment. - Replaced string-based `Sentry.captureException("...")` calls in blockaid.ts with `new Error(...)` wraps where the error is a body- signaled JSON `error` field rather than an HTTP-level failure; fetch-error paths route through `captureFetchError`. - Tests expanded: 8 cases now cover the non-JSON branch, truncation marker, Content-Length skip, and `res.text()` rejection-with-cause. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Pushed
The PR description has been updated to reflect the expanded scope and the production bugs that are not fixed here (the actual 429/400/431 root causes — those are now in #2747's follow-up list and will become triageable as separate Sentry issues once the new error shapes start splitting). Note: the failed |
| */ | ||
| export const captureFetchError = (err: unknown): void => { | ||
| if (err instanceof FetchError) { | ||
| Sentry.withScope((scope) => { |
|
@copilot resolve the merge conflicts in this pull request |
# Conflicts: # extension/src/popup/helpers/blockaid.ts
* Fix GrantAccess tests broken on master Three Jest specs in GrantAccess.test.tsx were failing on master: 1. "shows a miss label when scan site returns a miss status" 2. "shows a malicious label when scan site returns a malicious flag" 3. "shows unable to scan label when scan site returns an error on Mainnet" (1) and (2) regressed in #2748 when scanSite migrated from raw fetch to fetchJson. fetchJson now reads res.headers.get("content-type") to gate JSON parsing; the existing fetch mocks didn't include a headers field, so the call threw, scanSite returned null, and the rendered label was "unable-to-scan" rather than "miss" or "malicious". Add a headers.get stub returning application/json. (3) was pre-existing. The mock replaced useScanSite with a scanSite that threw synchronously. useAsyncSiteScan chains .then().catch() on the call result, so a sync throw bypasses .catch and leaves scanData undefined (which getSiteSecurityStates treats as an in-flight scan, suppressing the warning UI). Switch to mocking global.fetch with a rejected promise — same exercise of the unable-to-scan path, but routed through the real catch in useScanSite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Summary
Fixes a Sentry grouping bug where every
fetchJsonfailure collapses into one issue (FREIGHTER-DMQ), and addresses adjacent error-reporting hygiene gaps surfaced during review. Closes #2747.What was broken (the original bug)
fetchJsondidthrow new Error(res.statusText)on non-2xx responses. Over HTTP/2 (Cloudflare + most modern backends),statusTextis empty in Chrome, producing a message-lessError. With no message, Sentry's stack-trace fingerprint is the only grouping signal — distinct API failures (429s, 400s, 431s, etc.) all collapsed into a single "No error message" issue.What this PR changes
This PR is broader than the minimum bug fix. Beyond adding the status code to the thrown error, it:
FetchErrorcarryingstatus,statusText,url(sanitized — query string dropped),method,body, and akinddiscriminator (http-error|non-json). Produces a stable, status-codederror.messagefor Sentry grouping.error.message— the inverse grouping bug, where every Cloudflare interstitial / gateway page would have been its own fingerprint.captureFetchError— a Sentry helper that unpacksFetchErrorfields intosetTag/setContextsohttp.status:429,http.method:POST, etc. become filterable in Sentry. (Sentry's defaultcaptureExceptiondoes not enumerate own-properties ofErrorsubclasses, so without this the new fields would be inert.)Content-Length-aware skip plus a 4 KB read cap and explicit…[truncated]marker. Prevents a multi-MB error payload from being slurped just to produce a 200-char snippet, and makes truncation visible to future readers.scanSite,scanAsset, andscanAssetBulkto go throughfetchJson— they were using rawfetchwith the same shape of bad error capture (raw strings passed toSentry.captureException).Sentry.captureException("...")calls inblockaid.tswithnew Error(...)wraps so Sentry always receives a realError(improves stack capture and grouping for body-signaled errors).cause—super(msg, { cause })so the chained underlying error appears in Sentry's exception detail panel.Why the expanded scope
The original ticket (#2747) only required item 1 (status in the message). Reviewer (@aristidesstaffieri) flagged items 2, 3, 5, 6 as legitimate adjacent gaps in the same files — fixing them in one PR avoids a string of follow-up PRs through the same surface area. Items 4 and 7 are bonus hardening (memory safety, debuggability) that came up during implementation.
What this PR does not fix (deliberately)
The actual production bugs that have been hiding inside FREIGHTER-DMQ are still present. Once this lands and Sentry starts splitting events by status code, separate issues will need to be triaged:
/scan-tx— rate-limit thrash; client-side retry/backoff likely also needed/scan-tx— likely a payload-shape regression in 5.40.0scan-txfetch — header bloat (Cookie / Authorization / baggage)These remain on the issue's follow-up list (#2747).
Test plan
extension/src/popup/helpers/__tests__/fetch.test.ts:statusText(the FREIGHTER-DMQ regression case)statusText(still produces a meaningful message)kind: "non-json", body NOT in message (stable grouping)Content-Length-based read skipres.text()rejection chained ascauseblockaid.test.tsx(27 tests) continues to pass.tsc --noEmit -p extension/tsconfig.jsonclean.🤖 Generated with Claude Code