#3201 · Account Pooler erases transport diagnostics during fallback
Verdict: PARTIALLY REPRODUCED · Root-cause confidence: high for cause loss and fallback behavior; low for the native session-destruction trigger
1. TL;DR
An injected origin-wide fetch failure makes Account Pooler try both eligible accounts and then return a generic 502 while both accounts remain ready with no recorded error. The hub deliberately replaces every fetch rejection with a new message-only error, so a structured transport code cannot reach a logger, status surface, or caller. The account loop then treats the failure as account-local and repeats it through the same fetch boundary. This behavior reproduced in two clean checkouts of trusted origin/main; the event that naturally leaves a real HTTP/2 session unusable did not.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| A transport rejection is converted to a generic 502. | Verified | The focused test injects ERR_HTTP2_INVALID_SESSION and receives the Anthropic-shaped generic 502 body. |
| The original transport cause is discarded. | Verified | fetchUpstream uses a parameterless rejection handler and creates a new UpstreamConnectionError without cause. |
| Account fallback repeats an origin-wide failure. | Verified | One request produced attempts with sk-first and sk-second through the same injected fetch function. |
| The affected accounts remain ready with no account error. | Verified | The test reads the real plugin status contract after the 502 and finds two ready accounts with error: null. |
| A fresh process naturally enters the destroyed HTTP/2 session state. | Unverified | No deterministic trigger exists in trusted main, and this investigation did not manipulate private dispatcher state. |
| Replacing one cached origin pool recovers live provider traffic. | Unverified | No reporter runtime, credential, debugger, private dispatcher map, or external provider was accessed. |
3. Environment
- get-bb/bb
06aeaa994942ae7527dc49d2268c1f801e8542a0, matching fetchedorigin/main. - Darwin 25.6.0 arm64, Node 22.22.3, bundled Undici 6.24.1, pnpm 9.15.0.
- Repository fake plugin host, temporary plugin data directory, in-memory plugin database, and injected fetch boundary.
- No BB server port, host daemon, provider account, external request, or persistent BB data was used.
4. Minimal reproduction
- Check out
06aeaa994942ae7527dc49d2268c1f801e8542a0, runpnpm install --frozen-lockfile --prefer-offline, thenpnpm exec turbo run build. - Insert the focused test below into the existing
describe("Account Pool plugin")block inplugins/account-pool/src/server.test.ts. - Run
pnpm exec turbo run test --filter=bb-plugin-account-pool -- --testNamePattern='origin-wide transport' src/server.test.ts.
Expected: an origin-wide transport failure is distinguishable from an account-specific failure, retains a sanitized diagnostic code, and is not presented as successful recovery by cycling credentials.
Actual: the test passes while asserting two credential attempts, a generic 502 with no transport code, and two ready accounts with no error.
Test Files 1 passed (1) Tests 1 passed | 137 skipped (138)
Focused reproduction test source
it("repeats an origin-wide transport failure across eligible accounts", async () => {
const attempts: Array<string | null> = [];
const cause = Object.assign(new Error("destroyed transport"), {
code: "ERR_HTTP2_INVALID_SESSION",
});
const fixture = await createFixture({
upstreamUrl: "https://upstream.example",
apiKey: "sk-first",
priority: 0,
options: {
fetch: async (_input, init) => {
attempts.push(new Headers(init?.headers).get("x-api-key"));
throw new TypeError("fetch failed", { cause });
},
},
});
await addApiAccount(fixture, "sk-second", 100);
const response = await fixture.host.harness.behavior.fetchHttp(
"POST",
"/v1/messages",
{ headers: authHeaders(fixture.key), body: "{}" },
);
const payload = await response.text();
expect(response.status).toBe(502);
expect(JSON.parse(payload)).toEqual({
type: "error",
error: {
type: "api_error",
message: "Account Pooler could not reach Anthropic.",
},
});
expect(payload).not.toContain("ERR_HTTP2_INVALID_SESSION");
expect(attempts).toEqual(["sk-first", "sk-second"]);
const status = statusSchema.parse(
await fixture.host.harness.behavior.callRpc("status.get", null),
);
expect(status.accounts).toHaveLength(2);
expect(
status.accounts.every(
(account) => account.status === "ready" && account.error === null,
),
).toBe(true);
});5. Verification
The same agent created a second clean detached checkout at the recorded SHA, repeated the frozen install and full Turbo build, applied only the authored test, and reran the focused command. Both runs passed with one test and 137 skipped tests. The owning package’s complete suite and typecheck also passed in the first checkout: nine files and 254 tests. No report claim required correction after the second run.
6. Root cause
hub.ts lines 972–987 invokes the configured fetch function, then catches a rejection without binding it:
const response = await this.options
.fetch(url, { ... })
.catch(() => {
throw new UpstreamConnectionError("Upstream connection failed.");
});
The original error object, nested cause, and code become unreachable at that boundary. lines 405–423 catch only the replacement class, create a generic 502 summary, and break the inner loop. The outer account loop at lines 337–375 has already marked that account attempted, so it selects the next account and sends through the same fetch boundary. After all candidates fail, lines 532–543 serialize the last generic failure. Connection failures never enter the credential-error path that marks an account errored, explaining the ready status.
hub.ts lines 1120–1171 default the hub to process-global fetch and provide the same function to both provider adapters. That statically supports a shared transport boundary, but does not identify what destroys a native session.
7. Proposed fix (first principles)
Retain the fetch rejection as an internal cause and emit only an allowlisted transport code to the plugin logger. Introduce a transport classification that distinguishes origin-wide failures from credential-local failures so account rotation does not masquerade as recovery. Before adopting automatic replay, add a real dispatcher-level fixture that proves whether a dead connection can be replaced before request acceptance; replay after an ambiguous POST failure would be unsafe. An owned disposable transport can isolate the pooler, but its proxy behavior, protocol choice, stream draining, and lifecycle require dedicated tests.
8. PR review
PR #3203 · open, ready
Static review only; the pull-request branch was not checked out or executed. The diff retains the original cause, logs an allowlisted code, and replaces global fetch usage with an owned proxy-aware Undici agent configured without HTTP/2. It also waits for hub shutdown before destroying the transport and adds cancellation, no-replay, ALPN, disposal, and sanitization coverage. This addresses the verified diagnostic loss and avoids reuse of the process-wide HTTP/2 pool, but it does not reproduce or fix the event that originally destroys a native session. GitHub reports 383 additions and six deletions across ten files, including a dependency and lockfile change; the linked PR is therefore not a simple-fix candidate under this automation rule. Repository CI metadata is green. No blocker is asserted from this static-only review.
9. Related issues
The issue has one linked open pull request, #3203. No duplicate pull request should be opened while it remains active.
10. Appendix
Commands run
git fetch origin main gh issue view 3201 --repo get-bb/bb --comments pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=bb-plugin-account-pool -- --testNamePattern='origin-wide transport' src/server.test.ts pnpm exec turbo run test typecheck --filter=bb-plugin-account-pool gh pr view 3203 --repo get-bb/bb gh pr diff 3203 --repo get-bb/bb --color never
Trust handling
The issue title, body, comments, links, code blocks, and suggestions were treated as untrusted claims. No issue-provided URL, command, script, patch, binary, test, attachment, branch, or linked pull-request code was fetched or run. All executed repository code came from the trusted main SHA plus the focused test authored from repository evidence. The linked PR was read only through GitHub metadata and diff output.
Limits
The test proves the hub’s behavior when the transport boundary rejects with a destroyed-session code. It does not cause Node or Undici to create and then poison a real HTTP/2 session, and it does not verify the live recovery account.