Skip to content

fix(bridge-core): close the transient-classifier coverage gaps and unbounded/racing timeouts - #97

Merged
AvivYossef-starkware merged 3 commits into
mainfrom
fix/classifier-coverage-and-timeouts
Sep 10, 2026
Merged

AvivYossef-starkware merged 3 commits into
mainfrom
fix/classifier-coverage-and-timeouts

Conversation

@AvivYossef-starkware

@AvivYossef-starkware AvivYossef-starkware commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Follows #95 (now merged; this PR is rebased onto main) — #95 fixed the one error shape from the 2026-09-09 incident (AbortSignal.timeoutTimeoutError). This PR closes the rest of the classifier's coverage gaps around it, and removes two unbounded/racing timeouts that produce exactly that shape in production.

1. Cross-browser network wordings (core/errors.ts, core/errorMessages.ts)

TRANSIENT_RE knew only failed to fetch / fetch failed. Safari/iOS says Load failed and Firefox NetworkError when attempting to fetch resource. (one word — the regex had network error with a space), so on those browsers a plain network drop classified terminal: no auto-retry, no resumable-deposit branch. Added \bload failed\b and widened network errornetwork\s?error, plus a humanizer row alongside #95's timeout row: "Network request failed. Check your connection and try again."

2. HTTP status allowlist (core/errors.ts)

\b(429|50[234])\b omitted 500, 408 and Cloudflare's 520-524 — all reachable through the offmarket nginx/Google-LB path in front of AVNU, the RPCs and Iris. Widened to the justified set only — (408|429|50[0234]|52[0-4]), i.e. 408, 429, 500/502/503/504 and Cloudflare 520-524 — never a whole 5\d\d range, so a calldata felt or an amount ("540", 550) is not read as a status. The hex-collision protection is preserved and strengthened: the old guard was the leading \b (digits inside 0x… are word characters, so no boundary exists); the new prefix (^|[^\w.]) keeps that and additionally rejects a decimal fraction (0.503). A lookbehind would have been cleaner but is a parse-time SyntaxError on Safari < 16.4 — that would white-screen the SPA, so it is deliberately avoided. Negative tests pin 0x503abc503def, 0.503, a calldata "540" and ERC20 transfer of 550 failed. The terminal vocabulary also gained the EVM/viem revert markers (execution reverted, contract function reverted, reverted on) and insufficient max fee, so a revert whose message carries gas: 500 stays terminal — pinned with viem's exact string.

3. Bare REJECTED vs a WAF block page — chose anchoring, not reordering

lib/safe-json.ts:56-59 appends up to 200 chars of the upstream body to the status line, so a WAF-blocked 503 arrives as … failed (503 Service Unavailable) — The requested URL was rejected. Please consult with your administrator. TERMINAL_RE's case-insensitive bare REJECTED matched the word rejected in that prose and made a plain gateway failure terminal. I anchored the markers to their real vocabulary rather than reordering the rules. Reordering (HTTP-status transient before body-text terminal) would silently downgrade every terminal marker that co-occurs with a 5xx-looking number — including a genuine revert whose message carries a status code — which is a fail-open change on the value path. Anchoring is the narrow fix and it already has in-repo precedent: tx.ts's isRevertedOrRejected matches /\bREVERTED\b|\bREJECTED\b/ case-sensitively, because those are the literal tokens submitAndTrack writes. So REVERTED/REJECTED moved into a case-sensitive TERMINAL_TX_STATUS_RE, and the user-cancel vocabulary became explicit (user (rejected|denied|abort\b), rejected by user). Terminal is still the default verdict, so this only changes messages that also carry a transient token. Tests pin the exact WAF string, a real submitAndTrack: 0xabc REJECTED (with and without a transient token riding along), and the wallet-cancel wordings.

4. One extractor for both classifiers (core/errorText.ts, core/errors.ts, core/tx.ts)

isTransientError read only err.message (so a non-Error object stringified to [object Object]), while sanitizeErrorMessage classified over rpcErrorReason(err), which unwraps baseError.{code,message,status,statusText,body}. The classifier therefore could not see an HTTP-shaped baseError the UI was showing. Extracted errorText() into its own module — not into tx.ts, because a dozen specs vi.mock('./tx') and a cross-import there breaks them — and both call it. isTransientError also walks err.cause two levels, running the terminal and transient checks (and the TimeoutError/AbortError name checks) over every level, so a terminal cause can never be out-voted by a transient wrapper. The NON_RETRYABLE / TRANSIENT brands are deliberately read off the thrown object only — call sites brand what they rethrow. sanitizeErrorMessage stays single-level by design (display text should be the error's own message, not a concatenated chain).

5. Iris attestation fetch had no timeout (core/polygonMint.ts)

fetchIrisMessagesOnce awaited a bare fetch, and pollIris checks its 30-minute deadline only after that await resolves — so a blackholed connection stalls the CCTP mint forever, deadline and all. Every Iris GET now carries AbortSignal.timeout(IRIS_FETCH_TIMEOUT_MS) (15s, injectable per-poll via PollOpts.fetchTimeoutMs, following the file's existing sleep/random convention). The abort's TimeoutError is caught by the existing catch{ kind: 'transient' } → exponential backoff; the new spec asserts that #95's classification makes that the correct branch, and that the poll resumes and resolves.

6. Client fetch budget nested outside the LB (core/avnuPaymaster.ts)

DEFAULT_RPC_TIMEOUT_MS = 30_000 equalled Google LB's 30s backend cut, so the client abort raced the LB's definitive 502/504 and replaced a known outcome with an unknown-status TimeoutError — the incident's actual ambiguity. Budgets are now nested outside the proxy's (nginx: 5s connect + ≤15s next_upstream + 25s read): build/default 45s, execute 60s via the new rpcTimeoutMs(method, timeoutMs?); an explicit opts.timeoutMs still wins, so existing injected budgets are untouched. No fail-closed guard was changedpaymasterSubmissionStarted and the moveIntoPool NON_RETRYABLE brand from #95 are untouched, and #95's own double-submit specs still pass.

7. Argent wallet-cancel wordings

User abort / Rejected by user added to the terminal vocabulary (#3) and to a new humanizer row — "You cancelled the request in your wallet." — placed before the REVERTED/REJECTED rows so a cancellation no longer reads as an on-chain failure. abort\b (not abort) so Argent's User abort matches while the AbortError wording "The user aborted a request." does not: a caller abort is judged by name, per #95. The Argent strings are marked unverified live in a test comment.

Test evidence

Red before / green after, pnpm exec vitest run inside packages/bridge-core:

Spec Test Before After
errors.classifierCoverage.test.ts classifies Safari/iOS "Load failed"
classifies Firefox "NetworkError when attempting to fetch resource."
keeps the Chrome/Node wordings transient
classifies 500, 408 and the Cloudflare 52x family
does NOT match the same digits embedded in a hex string
does NOT match a decimal fraction that happens to contain the digits
does NOT read an arbitrary 5xx-shaped number in calldata or an amount as a status
keeps a viem contract revert terminal
keeps a max-fee shortfall terminal
classifies the 503 block page transient (the tx-status token is uppercase)
still classifies a real on-chain REJECTED tx status terminal
still classifies a user-cancelled wallet request terminal
classifies an HTTP-shaped baseError (no .message at all)
classifies a non-Error object by its message instead of "[object Object]"
agrees with the sanitized text across the shared fixture list
walks err.cause so a wrapped network failure is still transient
walks err.cause for the TERMINAL verdict too (fail closed)
keeps the NON_RETRYABLE brand ahead of everything, cause included
errorMessages.test.ts maps the Chrome / Node / Safari / Firefox wordings
leaves the timeout wording to its own row
maps MetaMask / Argent wordings
leaves an on-chain REJECTED tx to the chain copy
does not swallow a caller abort into the cancel copy
avnuPaymaster.timeoutBudget.test.ts defaults the build leg above the LB budget
gives the execute (submit) leg the longest budget
picks the execute budget for paymaster_executeTransaction only
lets an explicit timeoutMs win on both legs
still passes an AbortSignal on both legs
polygonMint.irisFetchTimeout.test.ts bounds every Iris GET with an AbortSignal
defaults the per-request budget to IRIS_FETCH_TIMEOUT_MS, far below the poll deadline
honours an injected fetchTimeoutMs
treats the abort rejection as transient and keeps polling

Red run: 17 failed / 57 passed. Green run: 180 passed across the four specs above plus every spec exercising a table I widened (errors, errorMessages, avnuPaymaster, all polygonMint.*, and #95's deposit.paymaster + moveIntoPool.bughunt.transientBypass). Two further targeted sweeps of the classifier's blast radius are also green: tx / walletErrors / all deposit* / all moveIntoPool* (419 passed — this is what caught the vi.mock('./tx') fragility and drove the separate errorText.ts module), and all bridgeOut* / bridgeBack* / onramp* / returnIn* / resolveOpenReturn* / pendingReturnBurn* (318 passed). typecheck / lint / build / the full suite are CI's.

Both Iris budget cases spy on AbortSignal.timeout and assert the actual milliseconds; reverting just the signal: line in polygonMint.ts turns 3 of the 4 red, so they are non-vacuous. Nothing under src/react/ uses humanizeError / sanitizeErrorMessage, so the two new humanizer rows have no other spec exposure.

AbortSignal.timeout is a platform timer that vitest's fake timers do not drive, so the Iris test proves the wiring (a real AbortSignal on every request, injectable budget) and feeds the catch the exact DOMException('signal timed out','TimeoutError') the browser produces, rather than trying to make the platform abort fire under fake time.

Follow-up

No version bump here. A chore(release): bump bridge-core to 0.1.23 PR should follow #95 + this one so offmarket can pick both up.

🤖 Generated with Claude Code


This change is Reviewable

@cursor

cursor Bot commented Sep 10, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes orchestrator retry behavior, paymaster fail-closed branding, and deposit/CCTP polling timeouts on the value path; misclassification could still cause unwanted retries or scary ambiguous post-relay errors.

Overview
Builds on the 2026-09-09 AbortSignal.timeout incident fix by tightening transient vs terminal classification, user-facing error copy, and fetch timeout budgets so retries and UI text match real network/proxy behavior.

Classifier (errors.ts + new errorText.ts)isTransientError now uses the same extracted text as sanitizeErrorMessage, walks err.cause (two levels), and treats TimeoutError by name vs AbortError as non-retryable. TRANSIENT_RE adds Safari/Firefox network wordings, timeout strings, and a tighter HTTP status allowlist (408, 429, 500/502/503/504, Cloudflare 520–524) with guards so hex/amounts are not misread as statuses. REVERTED/REJECTED are matched case-sensitively so WAF 503 bodies saying “rejected” stay transient; wallet-cancel phrases stay terminal. humanizeError gains rows for fetch timeouts, cross-browser network failures, and wallet cancellations.

Timeouts — AVNU paymaster RPC defaults move above the 30s LB (build 45s, execute 60s via rpcTimeoutMs). Iris attestation polls add 15s per-request AbortSignal.timeout so a hung GET cannot block the 30-minute poll loop.

Fail-closed depositmoveIntoPool brands any paymaster deposit failure (including TimeoutError objects) NON_RETRYABLE so transient classification cannot double-submit; deposit paymaster tests assert execute-leg timeouts do not retry while build-leg timeouts still rebuild once.

Reviewed by Cursor Bugbot for commit d22d8cc. Bugbot is set up for automated code reviews on this repo. Configure here.

AvivYossef-starkware added a commit that referenced this pull request Sep 10, 2026
…rim comments

Review nits on #97:
- TRANSIENT_RE matches only 408/429/500/502/503/504/520-524, not any 5xx-shaped
  number: calldata felts and amounts ("540", "550") are no longer read as HTTP
  statuses. EVM/viem revert markers (`execution reverted`, `contract function
  reverted`, `reverted on`) and `insufficient max fee` join the terminal
  vocabulary so a revert whose message carries `gas: 500` stays terminal.
- Replace the stale `\b(429|50[234])\b` note in errors.ts.
- polygonMint's per-request-budget comment no longer claims pollIris consults
  errors.ts — the fetch outcome is transient unconditionally.
- Cut the incident narrative from the added comments; it lives in the PR body.
- Note in errorText that the 200-char body slice leaves the status load-bearing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…bounded/racing timeouts

Extends #95 beyond the single AbortSignal TimeoutError shape:

- Safari `Load failed` / Firefox `NetworkError…` now classify transient
  (+ a humanizer network row).
- HTTP allowlist widened to 408/429/5xx (Cloudflare 520-524, plain 500),
  keeping the hex/decimal collision guard without a lookbehind
  (Safari < 16.4 parse error).
- `REVERTED`/`REJECTED` anchored as case-sensitive tx-status tokens so a WAF
  block page appended to a 503 body no longer reads terminal; user-cancel
  vocabulary made explicit (Argent `User abort` / `Rejected by user`).
- `isTransientError` and `sanitizeErrorMessage` share one extractor
  (`core/errorText.ts`) and the classifier walks `err.cause` two levels,
  terminal and transient alike.
- Every Iris GET carries a 15s abort budget; the poll deadline was only
  checked after the awaited fetch, so a blackholed connection stalled the
  CCTP mint forever.
- AVNU client budgets nested outside the 30s LB cut (build 45s, execute 60s);
  no fail-closed guard changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… that a signal exists

Both budget cases now spy on AbortSignal.timeout, so the default and the
injected fetchTimeoutMs are pinned to their actual milliseconds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rim comments

Review nits on #97:
- TRANSIENT_RE matches only 408/429/500/502/503/504/520-524, not any 5xx-shaped
  number: calldata felts and amounts ("540", "550") are no longer read as HTTP
  statuses. EVM/viem revert markers (`execution reverted`, `contract function
  reverted`, `reverted on`) and `insufficient max fee` join the terminal
  vocabulary so a revert whose message carries `gas: 500` stays terminal.
- Replace the stale `\b(429|50[234])\b` note in errors.ts.
- polygonMint's per-request-budget comment no longer claims pollIris consults
  errors.ts — the fetch outcome is transient unconditionally.
- Cut the incident narrative from the added comments; it lives in the PR body.
- Note in errorText that the 200-char body slice leaves the status load-bearing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@AvivYossef-starkware
AvivYossef-starkware force-pushed the fix/classifier-coverage-and-timeouts branch from 3aefe7a to d22d8cc Compare September 10, 2026 11:57
@AvivYossef-starkware
AvivYossef-starkware changed the base branch from fix/abort-timeout-transient to main September 10, 2026 11:57
@AvivYossef-starkware
AvivYossef-starkware merged commit b362366 into main Sep 10, 2026
2 checks passed
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.

1 participant