Skip to content

Throw FetchError with HTTP status from fetchJson#2748

Merged
piyalbasu merged 4 commits into
stellar:masterfrom
piyalbasu:piyal/fix-fetch-error-status-code
May 6, 2026
Merged

Throw FetchError with HTTP status from fetchJson#2748
piyalbasu merged 4 commits into
stellar:masterfrom
piyalbasu:piyal/fix-fetch-error-status-code

Conversation

@piyalbasu
Copy link
Copy Markdown
Contributor

@piyalbasu piyalbasu commented May 4, 2026

Summary

Fixes a Sentry grouping bug where every fetchJson failure collapses into one issue (FREIGHTER-DMQ), and addresses adjacent error-reporting hygiene gaps surfaced during review. Closes #2747.

What was broken (the original bug)

fetchJson did throw new Error(res.statusText) on non-2xx responses. Over HTTP/2 (Cloudflare + most modern backends), statusText is empty in Chrome, producing a message-less Error. 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:

  1. Throws a structured FetchError carrying status, statusText, url (sanitized — query string dropped), method, body, and a kind discriminator (http-error | non-json). Produces a stable, status-coded error.message for Sentry grouping.
  2. Fixes the non-JSON content-type branch, which previously interpolated arbitrary HTML/text bodies into error.message — the inverse grouping bug, where every Cloudflare interstitial / gateway page would have been its own fingerprint.
  3. Adds captureFetchError — a Sentry helper that unpacks FetchError fields into setTag / setContext so http.status:429, http.method:POST, etc. become filterable in Sentry. (Sentry's default captureException does not enumerate own-properties of Error subclasses, so without this the new fields would be inert.)
  4. Caps body readsContent-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.
  5. Migrates scanSite, scanAsset, and scanAssetBulk to go through fetchJson — they were using raw fetch with the same shape of bad error capture (raw strings passed to Sentry.captureException).
  6. Replaces string-based Sentry.captureException("...") calls in blockaid.ts with new Error(...) wraps so Sentry always receives a real Error (improves stack capture and grouping for body-signaled errors).
  7. Preserves body-read failures via causesuper(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:

  • HTTP 429 on /scan-tx — rate-limit thrash; client-side retry/backoff likely also needed
  • HTTP 400 on /scan-tx — likely a payload-shape regression in 5.40.0
  • HTTP 431 on an earlier non-scan-tx fetch — header bloat (Cookie / Authorization / baggage)

These remain on the issue's follow-up list (#2747).

Test plan

  • 8 jest unit tests in extension/src/popup/helpers/__tests__/fetch.test.ts:
    • 2xx success
    • 429 with empty statusText (the FREIGHTER-DMQ regression case)
    • 400 with status / sanitized URL / method / body in message
    • 431 with empty statusText (still produces a meaningful message)
    • non-JSON 2xx → kind: "non-json", body NOT in message (stable grouping)
    • body truncation marker
    • Content-Length-based read skip
    • res.text() rejection chained as cause
  • Existing blockaid.test.tsx (27 tests) continues to pass.
  • tsc --noEmit -p extension/tsconfig.json clean.
  • After merge, watch FREIGHTER-DMQ in Sentry to confirm new events stop landing there and split into status-specific issues.

🤖 Generated with Claude Code

`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>
Copilot AI review requested due to automatic review settings May 4, 2026 13:49
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 FetchError subclass that carries status, statusText, and optional body, and formats a more informative error message.
  • Updated fetchJson to throw FetchError on 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.

Comment thread extension/src/popup/helpers/fetch.ts Outdated
Comment on lines +6 to +9
const detail = body ? `: ${body.slice(0, 200)}` : "";
super(
`Request failed with status ${status}${statusText ? ` (${statusText})` : ""}${detail}`,
);
Comment thread extension/src/popup/helpers/fetch.ts Outdated
Comment on lines +20 to +26
let body: string | undefined;
try {
body = await res.text();
} catch {
// body unreadable — keep status-only message
}
throw new FetchError(res.status, res.statusText, body);
@aristidesstaffieri
Copy link
Copy Markdown
Contributor

Nice fix on the !res.ok path. A few adjacent error-reporting gaps worth considering before / after merge:

1. The non-JSON content-type branch is still bare Error (same FREIGHTER-DMQ shape)

fetch.ts:9 still does:

throw new Error(`Did not receive json error:${content}`);

The body is interpolated into error.message, so each unique HTML/text payload (Cloudflare interstitials, gateway pages, etc.) becomes its own Sentry fingerprint — the inverse grouping bug. Worth giving this branch the FetchError treatment too (status 200-but-wrong-content-type, body in a property, stable message). The new test file has no case for it.

2. status / body properties are dead weight in Sentry as currently wired

Callers use plain Sentry.captureException(err) (e.g. blockaid.ts:132, :211, :656, :692). Sentry's default capture only reads message + stack; it does not enumerate own-properties on Error subclasses into tags/contexts. So you'll see "429" in the title but you can't filter http.status:429, alert on 5xx-only, or pivot by body.

To actually unlock the new fields, route through a small helper:

export function captureFetchError(err: unknown) {
  if (err instanceof FetchError) {
    Sentry.withScope((scope) => {
      scope.setTag("http.status", String(err.status));
      scope.setContext("http", { statusText: err.statusText, body: err.body });
      Sentry.captureException(err);
    });
    return;
  }
  Sentry.captureException(err);
}

…and swap the four fetchJson callers to it. Bonus: routing body through setContext runs Sentry's data scrubbers on it (see #4).

3. No URL / method on the error

"Request failed with status 429" doesn't say which endpoint. The stack helps in Sentry but log search and titles benefit from the URL. Suggest new FetchError(url, method, status, statusText, body?) with a sanitized URL (drop query string — report-asset-warning?address=…&details=… and the others contain user addresses / signed XDR).

4. body.slice(0, 200) is silent truncation + a PII vector

  • No / [truncated] marker, so future readers can't tell content was cut.
  • 200 chars of arbitrary response body flowing into Sentry error.value (which Sentry indexes/displays) can include user addresses or signed XDR depending on endpoint. Prefer parsing JSON and surfacing a structured code/message, or keep message status-only and put body in scope.setContext so Sentry's scrubbers apply.

5. scanAsset and scanSite don't go through fetchJson — they're left out of the fix

  • blockaid.ts:188 (scanAsset) uses raw fetch and on failure calls Sentry.captureException(response.error || "Failed to scan asset") — passing a string, which Sentry groups by exact text.
  • Same shape at :542 / :582 (Failed to call scan site: ${error}) and :608.

If the goal is "fix Sentry grouping for indexer calls," these are the next items on the list — either migrate them to fetchJson or wrap their failures in FetchError.

6. Body-read failure swallows the underlying error

The empty catch {} around await res.text() discards the real cause. super(msg, { cause: e }) (supported at the ES2019 target) preserves it for Sentry's chained-exception display.

7. Test gaps worth adding

  • The non-JSON content-type branch (Run eslint during build, minor adjustments #1).
  • Truncation behavior (verifies cutoff and any marker added).
  • The res.text()-rejects path — easy to mock, today nothing pins down "stays status-only."

The single highest-leverage follow-up is #2 + #3: a captureFetchError helper plus URL on the error converts the new error-shape investment into actually-queryable Sentry data. Then #1 closes the loop so FREIGHTER-DMQ doesn't just migrate to a sibling issue on the JSON-content-type branch.

@aristidesstaffieri
Copy link
Copy Markdown
Contributor

aristidesstaffieri commented May 4, 2026

Nice fix on the !res.ok path. A few adjacent error-reporting gaps worth considering before / after merge:

1. The non-JSON content-type branch is still bare Error (same FREIGHTER-DMQ shape)

fetch.ts:9 still does:

throw new Error(`Did not receive json error:${content}`);

The body is interpolated into error.message, so each unique HTML/text payload (Cloudflare interstitials, gateway pages, etc.) becomes its own Sentry fingerprint — the inverse grouping bug. Worth giving this branch the FetchError treatment too (status 200-but-wrong-content-type, body in a property, stable message). The new test file has no case for it.

2. status / body properties are dead weight in Sentry as currently wired

Callers use plain Sentry.captureException(err) (e.g. blockaid.ts:132, :211, :656, :692). Sentry's default capture only reads message + stack; it does not enumerate own-properties on Error subclasses into tags/contexts. So you'll see "429" in the title but you can't filter http.status:429, alert on 5xx-only, or pivot by body.

To actually unlock the new fields, route through a small helper:

export function captureFetchError(err: unknown) {
  if (err instanceof FetchError) {
    Sentry.withScope((scope) => {
      scope.setTag("http.status", String(err.status));
      scope.setContext("http", { statusText: err.statusText, body: err.body });
      Sentry.captureException(err);
    });
    return;
  }
  Sentry.captureException(err);
}

…and swap the four fetchJson callers to it. Bonus: routing body through setContext runs Sentry's data scrubbers on it (see #4).

3. No URL / method on the error

"Request failed with status 429" doesn't say which endpoint. The stack helps in Sentry but log search and titles benefit from the URL. Suggest new FetchError(url, method, status, statusText, body?) with a sanitized URL (drop query string — report-asset-warning?address=…&details=… and the others contain user addresses / signed XDR).

4. body.slice(0, 200) is silent truncation + a PII vector

  • No / [truncated] marker, so future readers can't tell content was cut.
  • 200 chars of arbitrary response body flowing into Sentry error.value (which Sentry indexes/displays) can include user addresses or signed XDR depending on endpoint. Prefer parsing JSON and surfacing a structured code/message, or keep message status-only and put body in scope.setContext so Sentry's scrubbers apply.

5. scanAsset and scanSite don't go through fetchJson — they're left out of the fix

  • blockaid.ts:188 (scanAsset) uses raw fetch and on failure calls Sentry.captureException(response.error || "Failed to scan asset") — passing a string, which Sentry groups by exact text.
  • Same shape at :542 / :582 (Failed to call scan site: ${error}) and :608.

If the goal is "fix Sentry grouping for indexer calls," these are the next items on the list — either migrate them to fetchJson or wrap their failures in FetchError.

6. Body-read failure swallows the underlying error

The empty catch {} around await res.text() discards the real cause. super(msg, { cause: e }) (supported at the ES2019 target) preserves it for Sentry's chained-exception display.

7. Test gaps worth adding

  • The non-JSON content-type branch (Run eslint during build, minor adjustments #1).
  • Truncation behavior (verifies cutoff and any marker added).
  • The res.text()-rejects path — easy to mock, today nothing pins down "stays status-only."

The single highest-leverage follow-up is #2 + #3: a captureFetchError helper plus URL on the error converts the new error-shape investment into actually-queryable Sentry data. Then #1 closes the loop so FREIGHTER-DMQ doesn't just migrate to a sibling issue on the JSON-content-type branch.

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>
@piyalbasu
Copy link
Copy Markdown
Contributor Author

Pushed 24cada4a addressing the review. Quick map of comments → response:

  1. Non-JSON branch — now throws FetchError with kind: "non-json". Body lives only on the property, not in error.message, so each body variant doesn't fragment grouping. Test added.
  2. status / body were dead weight — added captureFetchError helper using Sentry.withScope + setTag("http.status", …) / setTag("http.method", …) / setTag("http.kind", …) + setContext("http", { url, method, status, statusText, body, kind }). Routing body through setContext also runs Sentry's scrubbers on it. Wired into all 7 blockaid.ts fetch-error sites.
  3. URL / method on the errorFetchError carries both. URL is sanitized (origin + pathname; query string dropped) so signed XDR / addresses in report-asset-warning?address=… etc. don't leak into error.value. Sanitized URL also goes to the property and to setContext.
  4. Body PII vector — skipped per your call. Mitigated indirectly via router fix #2 (scrubbers) and Piyal dev #3 (sanitized URL).
  5. scanAsset / scanSite / scanAssetBulk outside fetchJson — all migrated to fetchJson. Plus the useAsyncSiteScan and fetchSiteScanData Sentry.captureException(\Failed to call scan site: ${error}`)string-captures replaced withcaptureFetchError(error). The res.errorJSON-body captures inreportAssetWarning/reportTransactionWarningare now wrapped innew Error(...)` (still plain Errors since they're not HTTP-level failures).
  6. catch {} swallows underlying errorreadBoundedText returns the rejection as readError, which is passed to super(msg, { cause }). New test asserts err.cause === readError.
  7. Test gaps — added cases for: non-JSON branch (no body in message), truncation marker, Content-Length skip, and res.text() rejection with cause.

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 test check on the prior commit was unrelated playwright flakes in accountHistory.test.ts (4 muxed-address / claimable-balance cases). Jest unit tests are green.

*/
export const captureFetchError = (err: unknown): void => {
if (err instanceof FetchError) {
Sentry.withScope((scope) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nice 👏

@piyalbasu
Copy link
Copy Markdown
Contributor Author

@copilot resolve the merge conflicts in this pull request

@piyalbasu piyalbasu merged commit 9e7af77 into stellar:master May 6, 2026
8 of 9 checks passed
piyalbasu added a commit that referenced this pull request May 7, 2026
* 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>
@github-actions github-actions Bot mentioned this pull request May 12, 2026
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.

fetchJson swallows HTTP error context, masking distinct failures in Sentry as one issue

3 participants