← reports

#3201 · Account Pooler erases transport diagnostics during fallback

Bug Medium Effort: Medium plugins providers open on GitHub 2026-09-08 · base 06aeaa994

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

ClaimStatusEvidence
A transport rejection is converted to a generic 502.VerifiedThe focused test injects ERR_HTTP2_INVALID_SESSION and receives the Anthropic-shaped generic 502 body.
The original transport cause is discarded.VerifiedfetchUpstream uses a parameterless rejection handler and creates a new UpstreamConnectionError without cause.
Account fallback repeats an origin-wide failure.VerifiedOne request produced attempts with sk-first and sk-second through the same injected fetch function.
The affected accounts remain ready with no account error.VerifiedThe 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.UnverifiedNo deterministic trigger exists in trusted main, and this investigation did not manipulate private dispatcher state.
Replacing one cached origin pool recovers live provider traffic.UnverifiedNo reporter runtime, credential, debugger, private dispatcher map, or external provider was accessed.

3. Environment

4. Minimal reproduction

  1. Check out 06aeaa994942ae7527dc49d2268c1f801e8542a0, run pnpm install --frozen-lockfile --prefer-offline, then pnpm exec turbo run build.
  2. Insert the focused test below into the existing describe("Account Pool plugin") block in plugins/account-pool/src/server.test.ts.
  3. 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.