Skip to content

fix(core,browser-utils): let the collector's code win over the status, and bound the browser retry - #3

Merged
krassx merged 1 commit into
mainfrom
fix/control-plane-classification
Sep 6, 2026
Merged

krassx merged 1 commit into
mainfrom
fix/control-plane-classification

Conversation

@krassx

@krassx krassx commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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 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. 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.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:

body HTTP 200 HTTP 400 (before) HTTP 400 (now)
{ok:false,error:{code:14019}} permanent, blob freed retried forever permanent, blob freed
{ok:false,error:{code:99099}} SDK switched off never switched off SDK switched off
{ok:false,error:{code:99013}} retried retried retried

The failed body is now read (httpFailure → serverErrorCodeOf) and its code goes on BugseeError.serverCode, where the existing, Android-drift-tested classifyServerErrorCode already 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, and undefined means retry: a non-JSON body (nginx / WAF / captive-portal page), no error object, a non-numeric code, and code: 0, which Android's optInt("code", 0) guards also treat as absent.

What did NOT become a verdict — a naked non-2xx

Android's /v2/issues path falls back to classifyHttpStatus, where any non-401/408/425/429 4xx is PERMANENT and the bundle file is deleted. This SDK deliberately does not follow it there.

  • Android's OWN /v2/sessions path falls back to classifyServerErrorCode(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.
  • On the web a bare 4xx is what an intermediary answers — a captive portal, a corporate MITM proxy, a WAF, a stale CDN route, a service worker. None of them read the bytes, and all of them go away.
  • Widening a deletion path on a status is what produced a new loss path in each of three consecutive review rounds here (R3-1: status >= 500 deleted 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

recoverSiblingBundleQueue reads 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 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 same TTL node's sweep uses.

Restraint, deliberately:

  • 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. Age is the one bound where "give up" and "this is worthless now" say the same thing.
  • A frame with no firstSeenMs is never expired. Reading unknown as the epoch would delete every pending report on the launch after an SDK upgrade. That rail is load-bearing: injecting firstSeenMs ?? 0 is caught 23 times over by the pre-existing P2 in harness set H.
  • Announced, not silent — pipeline.drop('retention_expired', 'issue'), so an evicted bundle is distinguishable from a delivered one.

Reproduced first, and the reproductions are the harness cases

  • Set M gains 16 control cases: the same envelopes over a non-2xx (expected verdict derived from SERVER_ERROR_CATEGORIES, not hand-declared) plus 6 naked statuses as a fence.
  • Set N is new: the browser age bound across four launches over real IndexedDB, with a no timestamp control that must be delivered.
pre-fix:  369 cases swept, 7 invariant violations   (M) — incl. "P7 the client KEPT CAPTURING
                                                    after the collector switched the SDK off"
pre-fix:  369 cases swept, 1 invariant violation    (N) — "it was still OFFERED 9x past the age bound"
post-fix: 369 cases swept, 0 invariant violations

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 committed packages/instrumentation-tests/harness/invariants.mts no 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:local green (lint · typecheck 100/100 · cycles · per-package coverage), and --force --e2e green (54 tasks, 195 e2e tests). Mutator loop run on every new/changed function: 10 mutations on serverErrorCodeOf/httpFailure and 10 on the age bound, all caught (one discarded as a compile break and replaced).

🤖 Generated with Claude Code

…, 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)
@krassx
krassx merged commit 67c51f4 into main Sep 6, 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