Repository navigation
fix(core,browser-utils): let the collector's code win over the status, and bound the browser retry - #3
Merged
Conversation
…, and bound the browser retry
The DATA plane classified server answers; the CONTROL plane did not, so a 400 from /v2/issues was
retried at every launch — bounded on node by the 7-day retention, and on browser/worker not bounded
at all (docs/review/OPEN-FINDINGS.md, "New open items from round 4"). Two halves, deliberately
different kinds of thing.
WHAT BECAME A VERDICT — the collector's own error code, when it arrives with a status attached.
`bugsee-api.ts` threw on `!isOk(status)` BEFORE parsing the body, so a verdict the SDK honours
perfectly on an HTTP 200 was discarded the moment the collector also sent a status: `14019
InvalidAppToken` on a 400 was retried forever, and `99099 KillSdk` on a 400 could not switch the SDK
off at all. The failed body is now read (`httpFailure` -> `serverErrorCodeOf`) and its code goes on
`serverCode`, where the existing drift-tested `classifyServerErrorCode` already sees it. No code was
added to the permanent set: the same table now simply reaches a response shape that was being thrown
away, which is what Android does on both endpoints (`ReportUploadExecutor.java:468-484`,
`CommunicationRequests.obtainSession`). Everything about the parse fails towards `undefined` — a
non-JSON body, no error object, a non-numeric code, and `code: 0`, which Android also treats as
absent — and `undefined` means retry.
WHAT DID NOT — a naked non-2xx. Android's /v2/issues path falls back to `classifyHttpStatus` (any
non-401/408/425/429 4xx -> PERMANENT -> the bundle file is deleted) and this SDK deliberately does not
follow it there, because Android's OWN /v2/sessions path falls back to `classifyServerErrorCode(0)` ->
TRANSIENT for the identical status. Two adjacent calls to one collector cannot both be right, which is
the tell that a status is a verdict about the request and not about the bytes; and on the web a bare
4xx is what an intermediary answers (captive portal, MITM proxy, WAF, stale CDN route, service
worker), none of which read the payload. Pinned by tests so the decision is falsifiable rather than an
omission. The already-decided doubly-403 signed PUT (`renew_failed`) is untouched.
WHAT BOUNDS THE REST — `recoverSiblingBundleQueue` now applies `DEFAULT_DURABLE_RETENTION.maxAgeMs`.
This is not a new policy: it is the same 7 days the same bytes already meet on the same tier when an
instance recovers its OWN queue, and the TTL node's dead-subtree sweep uses. Age only — the count and
byte caps evict the oldest SURVIVORS, which on this leg would delete a report the collector was never
asked about merely because a burst arrived after it. A frame with no `firstSeenMs` is NEVER expired:
reading unknown as the epoch would delete every pending report on the launch after an upgrade, and
that rail is load-bearing — injecting `firstSeenMs ?? 0` is caught 23 times over by the pre-existing
P2 in harness set H. Evictions are announced via `pipeline.drop('retention_expired', 'issue')`, never
silent.
Reproduced before fixing, and the reproductions are the harness cases. Set M gains 16 control cases
(the same envelopes over a non-2xx, plus 6 naked statuses as a fence) and a new set N drives the
browser age bound across four launches over real IndexedDB. Pre-fix: 7 violations in M, including
"P7 the client KEPT CAPTURING after the collector switched the SDK off", and "it was still OFFERED 9x
past the age bound" in N. Post-fix: 369 cases, 0 violations.
The harness oracle was checked before being relied on: the flaw recorded in OPEN-FINDINGS Evidence
(keying on `reportId ?? 'anon:' + summary`) is in the ROUND-1 copy under `.session-artifacts/`. The
committed harness no longer does that and imports no SDK predicate.
Core also exports `deserializeBundleFrame` and `DEFAULT_DURABLE_RETENTION`, so the bound is read from
one place rather than restated.
Gates: pnpm ci:local green (lint, typecheck 100/100, cycles, per-package coverage) plus --force --e2e
(54 tasks, 195 e2e tests).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the round-4 open item "the control plane never classifies at all" (
docs/review/OPEN-FINDINGS.md).The DATA plane classified server answers; the CONTROL plane did not, so a
400from/v2/issueswas retried at every launch — bounded on node by the 7-day retention, and on browser/worker not bounded at all. The fix is in two halves that are deliberately different kinds of thing: one is a verdict, one is a bound, and the whole point is not to use the first to make up for a missing second.The decision
What became a verdict — the collector's own error code, when it arrives with a status attached
bugsee-api.tsthrew on!isOk(status)before parsing the body, so a verdict the SDK honours perfectly on an HTTP 200 was discarded the moment the collector also sent a status:{ok:false,error:{code:14019}}permanent, blob freedpermanent, blob freed{ok:false,error:{code:99099}}{ok:false,error:{code:99013}}The failed body is now read (
httpFailure→serverErrorCodeOf) and its code goes onBugseeError.serverCode, where the existing, Android-drift-testedclassifyServerErrorCodealready looks. No code was added to the permanent set — the same table now simply reaches a response shape that was being thrown away, which is what Android does on both endpoints (ReportUploadExecutor.java:468-484,CommunicationRequests.obtainSession).Every failure of the parse falls to
undefined, andundefinedmeans retry: a non-JSON body (nginx / WAF / captive-portal page), no error object, a non-numeric code, andcode: 0, which Android'soptInt("code", 0)guards also treat as absent.What did NOT become a verdict — a naked non-2xx
Android's
/v2/issuespath falls back toclassifyHttpStatus, where any non-401/408/425/429 4xx is PERMANENT and the bundle file is deleted. This SDK deliberately does not follow it there./v2/sessionspath falls back toclassifyServerErrorCode(0)→ TRANSIENT for the identical status. Two adjacent calls to the same collector cannot both be right, and the disagreement is the tell: a status is a verdict about the request that carried the payload, not about the payload.status >= 500deleted every 401/408/425/429).Pinned by tests (
upload-pipeline.test.ts— "a status is never a verdict here") and by six harness cases, so the decision is falsifiable rather than an omission. The already-decided doubly-403 signed PUT (renew_failed) is untouched.What bounds the rest — the browser/worker age bound
recoverSiblingBundleQueuereads a dead instance's prefix directly, so it met neither node's dead-subtree sweep nor the durable queue's own retention — and on the web an instance is dead the moment its tab closes. It now appliesDEFAULT_DURABLE_RETENTION.maxAgeMs.This is not a new policy: it is the same 7 days the same bytes already meet on the same tier when an instance recovers its own queue, and the same TTL node's sweep uses.
Restraint, deliberately:
firstSeenMsis never expired. Reading unknown as the epoch would delete every pending report on the launch after an SDK upgrade. That rail is load-bearing: injectingfirstSeenMs ?? 0is caught 23 times over by the pre-existing P2 in harness set H.pipeline.drop('retention_expired', 'issue'), so an evicted bundle is distinguishable from a delivered one.Reproduced first, and the reproductions are the harness cases
SERVER_ERROR_CATEGORIES, not hand-declared) plus 6 naked statuses as a fence.no timestampcontrol that must be delivered.Set N carries its own assertions rather than going through
judge, because retention is the one licensed exception to P2 ("nothing is deleted that the collector has not taken or refused") — giving up on a blob is by definition deleting one it never answered about. Weakening P2 for everyone to accommodate that is how a real loss would hide. Recorded in the harness README.The harness oracle was checked before being relied on. The flaw recorded in OPEN-FINDINGS §Evidence — keying an incident on
reportId ?? 'anon:' + summary— is in the round-1 copy under.session-artifacts/. The committedpackages/instrumentation-tests/harness/invariants.mtsno longer does that, imports no SDK predicate, and derives its expected control-plane intents from the drift-tested table. Verified by reading it, not assumed.Gates
pnpm ci:localgreen (lint · typecheck 100/100 · cycles · per-package coverage), and--force --e2egreen (54 tasks, 195 e2e tests). Mutator loop run on every new/changed function: 10 mutations onserverErrorCodeOf/httpFailureand 10 on the age bound, all caught (one discarded as a compile break and replaced).🤖 Generated with Claude Code