Skip to content

Disabling Account Pooler breaks Claude: running turns 503-retry for ~3 min, new threads fail with an expired local OAuth login #4352

Open

Description

@brsbl

Summary

Disabling the Account Pooler plugin while Claude Code turns are running breaks those turns. Each running Claude session still has ANTHROPIC_BASE_URL pointed at the pooler's server route. After the plugin stops, that route returns 503 plugin "account-pool" is not running (status: disabled). Claude Code treats a 503 as temporary, retries 10 times over about 3 minutes, then fails the turn with a message saying the problem is "usually temporary."

Expected: bb recognizes that the pooler is gone. Running Claude sessions should switch to their own login, or the turn should fail right away with an accurate message. Neither should spend minutes retrying a route that was turned off on purpose.

New threads fail too, with a different error. After disabling, new Claude threads (and new turns on existing threads) correctly start without the pooler's env. But their first turn fails immediately with Failed to authenticate: OAuth session expired and could not be refreshed, because the machine's own Claude Code login no longer works. Turning the pooler off left this machine with no working Claude credentials at all. The only way to recover was to turn the pooler back on (or re-run claude login).

Versions and environment

  • bb 0.43.4-nightly.35739502552.1 (macOS host enrolled to a remote bb server; BB_SERVER_URL=http://127.0.0.1:59089 on the host)
  • Provider: Claude Code (claude-code), Opus 5.5
  • Account Pooler (built-in account-pool@0.1.0), with Claude routing enabled
  • Code references are at main @ 1a4ddf493f64b38978cfb00ab9a26b7b7737116a

Steps to reproduce

  1. With Account Pooler enabled and serving Claude, start a Claude Code thread and send a prompt that runs for a minute or more.
  2. While the turn is running, disable the plugin (Settings → Plugins → Account Pooler → disable).
  3. Watch the running thread.

Expected vs actual

Actual: On the next model request, the running turn gets 503s from the disabled plugin route. It then goes through Claude Code's full retry backoff and fails.

16:18:57Z provider/error  Claude Code API retry 1/10 after 623ms: HTTP 503 server_error
...
16:21:25Z provider/error  Claude Code API retry 10/10 after 37724ms: HTTP 503 server_error
16:22:04Z provider/error  API Error: 503 {"ok":false,"error":"plugin \"account-pool\" is not running (status: disabled)"}. This is a server-side issue, usually temporary — try again in a moment. If it persists, check your inference gateway (127.0.0.1:59089).
16:22:04Z turn/completed  status: failed

The UI shows "Worked for 3m 21s", then "Provider internal error" with the message above.

Expected: Disabling the pooler should not waste minutes of retries or leave the thread in error. The error text should also not tell the user to "try again in a moment" when the plugin was disabled on purpose.

Evidence

Observed on two threads that were mid-turn when the pooler was disabled (around 16:18:50Z on 2026-09-25):

Thread Turn started First 503 Turn failed
thr_p4272j758k 16:18:19Z 16:18:57Z 16:22:04Z
thr_fd26dujhcx 16:18:46Z 16:18:58Z 16:22:07Z

The provider.env-resolved event for thr_p4272j758k (seq 30) confirms the session's env came from the pooler:

ANTHROPIC_BASE_URL   source={plugin: account-pool}  http://127.0.0.1:59089/api/v1/plugins/account-pool/http
ANTHROPIC_AUTH_TOKEN source={plugin: account-pool}  <redacted>
ENABLE_TOOL_SEARCH   source={plugin: account-pool}  true

New threads after disable: local Claude login is dead

Every Claude thread whose first turn ran after the pooler was disabled failed within about a second. provider.env-resolved for each shows no pooler entries, so bb correctly stopped routing through the pooler. The failure is the local credential:

Thread Created Env at first turn Result
thr_pspxvua656 16:21:46Z no pooler env (seq 31) 16:22:29Z Failed to authenticate: OAuth session expired and could not be refreshed, turn failed
thr_dkhvpa3yvi 16:23:03Z no pooler env (seq 31) 16:23:39Z same error, turn failed

Both threads worked again after the pooler was re-enabled (about 16:48Z). Their next provider.env-resolved shows ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN sourced from account-pool again, and the turns succeeded. thr_p4272j758k (created 16:17:45Z) started just before the disable, so it got the pooler env and hit the 503 path above instead.

Likely cause (hypothesis, not confirmed): one of the pooler's ways to add an account is importing the machine's existing Claude Code login (credentials.ts#L149-L228, claude-adapter.ts#L60-L79). That copies both the access token and the refresh token. From then on the pool refreshes the account itself and stores the new tokens only in its own secret store (claude-adapter.ts#L106-L131, refreshToken: parsed.refresh_token ?? secret.refreshToken). If Anthropic rotates refresh tokens on each use, the first refresh by the pool invalidates the copy in the machine's keychain / ~/.claude/.credentials.json. Meanwhile, every session on the machine goes through the pooler, so nothing refreshes the local login. When the pooler is disabled, Claude Code falls back to a refresh token that is already dead.

The pool in this report has a single Claude account (Max 5x, added 2026-09-23, most recently used from this Mac). The pool doesn't record whether an account was imported or added through browser sign-in, and I did not read the machine's keychain to compare. So this cause is inferred from the code, not confirmed.

Why it happens (code path)

  1. The pooler injects ANTHROPIC_BASE_URL as a serverPath pointing at its own HTTP route: plugins/account-pool/src/server.ts#L261-L306.
  2. Provider env is resolved on the server at turn start (thread-runtime-config.ts#L197-L210). The Claude bridge only applies a changed env when the next turn starts, by rebuilding the session (bridge.ts#L1504-L1529). Nothing updates or stops an in-flight Claude process when a plugin that contributed its env is disabled.
  3. After disable, the plugin HTTP proxy returns 503 for a stopped plugin (apps/server/src/routes/plugins.ts#L856-L867). Claude Code treats 503 as a temporary overload and retries with backoff up to 10 times (about 3 minutes), then shows its generic "usually temporary" text.
  4. The pooler's onDispose hook only logs a warning about recently routed threads without a local Claude login (server.ts#L326-L351). It does not warn the user that running turns will break.

Possible fixes (for triage, not prescriptive)

  • On plugin disable, restart or interrupt running provider sessions whose resolved env includes entries with source.plugin === <disabled plugin>, so they pick up the new env. Alternatively, mark those threads so the next request is retried without the pooler.
  • For a plugin that is disabled (not starting or reloading), return a non-retryable status from /plugins/:id/http/* so Claude Code fails fast instead of retrying for minutes. Keep 503 for transient states such as starting and "reloaded during the request".
  • Show an accurate message in bb (for example "Account Pooler was disabled while this turn was running — retry to use this machine's Claude login") instead of the provider's "check your inference gateway" text.
  • Optionally, warn in the disable flow when threads are currently routed through the pooler.
  • Do not leave the machine without working credentials after disabling. Options: write refreshed tokens back to the machine's Claude Code credential store for accounts imported from it (or never refresh an imported account's token in a way that orphans the local copy); check the local login in the disable flow and block or warn with "run claude login" instead of only logging it in onDispose; or show the auth failure in bb with a direct "Sign in to Claude on this machine" action.

What I ruled out

Suggested priority and effort

Medium-to-high priority, medium effort. Disabling the pooler breaks every running Claude turn, and (if the hypothesis above holds) every new Claude thread on machines whose login was imported into the pool, until the user re-enables the pooler or re-runs claude login. Work in progress is lost for about 3 minutes, and the thread ends in error. Workaround: stop running Claude threads before disabling the pooler, and run claude login on the machine afterward (or turn the pooler back on).

BB-Thread-ID: thr_dkhvpa3yvi

AGENT GENERATED

Activity

  1. added
    providersCross-provider bridges, models, login
    pluginsPlugin SDK, runtime, marketplace
    on Sep 25, 2026
  2. changed the title [-]Disabling Account Pooler mid-turn fails running Claude turns after ~3 min of 503 retries[/-] [+]Disabling Account Pooler breaks Claude: running turns 503-retry for ~3 min, new threads fail with an expired local OAuth login[/+] on Sep 25, 2026
  3. added theissue type on Sep 25, 2026
  4. added
    partial-reproBug partially reproduced; some claims unverified; see linked report
    on Sep 25, 2026
  5. bb-slop-cop commented on Sep 25, 2026

    @bb-slop-cop
    Contributor

    🚨 SLOP COP 🚨 · new-issue-autopilot

    Preserved Bug · Priority: Medium · Effort: Medium and providers, provider-claude-code, plugins. Added partial-repro.

    Reproduction report: PARTIALLY REPRODUCED, high confidence in the HTTP root cause. Two clean checkouts of trusted main reproduced a working plugin route returning 503 after disable; the focused non-server-error regression assertion failed in both. The proxy treats intentional disablement like a transient unavailable state. Claude retry timing and OAuth invalidation remain unverified.

    No PR: selecting shutdown behavior and changing the HTTP contract or credential ownership exceeds this rule’s simple-fix limits.

    AGENT GENERATED

  6. seklow commented on Oct 23, 2026

    @seklow

    This is live evidence for the "Likely cause" hypothesis in this issue, from a second installation. The hypothesis holds: --import gives the pool the same refresh-token chain as the host's Claude Code login, and the two holders keep taking it from each other. One assumption in it does not hold: pool-routed Claude Code threads still refresh the host login, so the host copy is never idle. I'm adding this here because it is the same root cause. If you would rather keep this issue about the disable/503 path, I can move the --import part to its own issue.

    Summary

    On one Linux host I added Claude accounts with bb pool account add --provider claude --import and kept using Claude Code on that host: bb threads routed through the pool, plus a terminal account switcher. Anthropic rotates the refresh token on every refresh and rejects the spent one at once. So whenever an access token came within 5 minutes of expiry, the pool and a host-side refresher raced for the same refresh token.

    • Over 3.7 days the host side refreshed first in 15 of 67 such windows.
    • 6 of those 15 ended in HTTP 400 invalid_grant for the pool, on 5 different pool accounts, and the pool marked the account error.
    • Twice, the failed account was the only eligible one, and pooled threads got a 429 that pointed 43 min and 2 h 10 min ahead.
    • When the pool wins instead, the host's copy is spent. According to the Claude Code binary, Claude Code then clears its stored login at its next refresh. That is the OAuth session expired and could not be refreshed failure reported above. I did not observe the wipe itself on this host.

    Meanwhile the pool docs say "Prefer --import for an existing Claude Code login".

    Versions and environment

    • bb 0.46.1 (latest release, desktop-v0.46.1), installed with npm (bb-app), running as a systemd user service. Linux x86_64 (Debian 13, kernel 6.12), Node v24.22.1.
    • Built-in Account Pooler (account-pool 0.1.0) with Claude routing on.
      • Six Claude subscription accounts and two Codex accounts were added on 2026-10-19 with --import, switching the host login between them.
      • One more Claude account (the reserve, "account E" below) was imported on 2026-10-22 at 10:12:47Z from the login that was active on the host.
    • Claude Code 2.1.306 to 2.1.308 when the races happened (2.1.309 now, with the same refresh logic). codex-cli 0.163.0.
    • Two unusual parts of this setup matter here:
      • A third-party Claude Code and Codex account switcher, ccswap v0.39.2, keeps a copy of each login, swaps one into ~/.claude/.credentials.json, and refreshes the idle copies when 5 minutes or less are left.
      • A local bb script automation ran about once a minute as a workaround for this race. It copied whichever copy was newer (by expiresAt) between the pool's secret files and the switcher, then ran bb pool account enable to clear the pool error.
        • Because of it, the shared grant survived each race, and the race came back every 8 hours.
        • Without it, each imported account would lose one side once and stay broken.
    • Code is cited at main @ 1e80ca7b44bccd06c6da75ed9ca6ba497bd380f5, and the behavior is unchanged on main.
      • Between desktop-v0.46.1 and that commit, plugins/account-pool changed only in row styling, env contribution without a thread id, and a JWT schema type. None of it touches import, refresh, remove, or the docs.
      • I also checked the installed account-pool/dist/server.js for the same import, refresh, and remove code.

    Steps to reproduce

    I saw this in normal use and did not trigger it on purpose, because each run costs either a pool account or the host login.

    1. On the bb server host, run claude /login. On Linux the login is stored in ~/.claude/.credentials.json.
    2. Run bb plugin enable account-pool, then bb pool account add --provider claude --import, then bb pool routing claude.
    3. Keep using Claude Code on that host. Pool-routed bb Claude threads are enough; a terminal claude works too.
    4. Wait until the access token is within 5 minutes of expiresAt. Tokens last 8 hours. The pool and Claude Code both refresh when now + 300 s >= expiresAt.
    5. Whichever side refreshes first wins:
      • Host first: the pool's next refresh gets HTTP 400 invalid_grant and the account goes to error. If it was the only eligible account, pooled threads get a 429.
      • Pool first: Claude Code's next refresh gets invalid_grant, Claude Code clears its stored login, and the host is signed out. This branch comes from the Claude Code binary, not from an observed wipe; see Evidence.

    Not observed: any Codex failure. Codex --import shares the ~/.codex/auth.json grant in the same way, but Codex tokens last about 10 days and no Codex refresh fell inside the window.

    Expected vs actual

    Actual. Pool log ~/.bb/plugins/account-pool/logs/plugin.log, verbatim except that account ids are replaced:

    {"ts":1792577155839,"level":"warn","message":"Account Pooler claude account <account D> OAuth refresh failed: OAuth refresh failed with HTTP 400. invalid_grant. expiresAt=1792577453039, forced=false."}
    {"ts":1792669618276,"level":"warn","message":"Account Pooler claude account <account E> OAuth refresh failed: OAuth refresh failed with HTTP 400. invalid_grant. expiresAt=1792669886129, forced=false."}
    {"ts":1792716590876,"level":"warn","message":"Account Pooler claude account <account B> OAuth refresh failed: OAuth refresh failed with HTTP 400. invalid_grant. expiresAt=1792716887319, forced=false."}
    

    The access token still had 297 s, 268 s and 296 s left, respectively. Right after the last line, bb thread log thr_cv4aev22bx shows the following; thr_cehnf3bqqh and thr_t9xt22j7id got the same 2 to 4 s later:

    2026-10-23T00:49:51.367Z provider/error
    API Error: Request rejected (429) · No Account Pooler account is currently eligible. Next account is expected to be available at 2026-10-23T02:59:59.798Z (in 7809 seconds).
    

    The external script cleared the account's error at 00:50:07Z.

    Expected. Adding an account should not quietly create a second, uncoordinated holder of a login that is still in use. Concretely:

    1. Docs recommend --login. Replace "Prefer --import for an existing Claude Code login" with a recommendation for --login. Describe --import as handing the login over to the pool: it is safe only if nothing on that machine uses the login again. Pool-routed Claude Code threads on the same machine count as users (see Evidence).
    2. Import warns. bb pool account add --import and the settings item "Import from this machine" should say two things: the pool and the host will share one single-use refresh token, and one of them will be signed out at the next refresh. They should point to Sign in instead, for example by requiring --yes on the CLI and a confirmation in the UI.
    3. Docs say that remove does not revoke. bb pool account remove only deletes the pool's entry and secret file; nothing is revoked upstream. Users should know what that means:
      • Removing an imported account does not sign the host out. But if the pool refreshed last, the host's copy is already spent, and the host needs claude /login.
      • A removed --login grant stays valid upstream until it expires.
    4. Optional: record where an account came from. The original report notes that the pool cannot tell an imported account from a signed-in one. With that recorded:

    Evidence

    All six pool invalid_grant failures in the window (UTC). Every pool-refreshed Claude token lasted exactly 28800 s (all 65 Claude refreshes in the log). So the other side's refresh time is the previousExpiresAt of the pool's next refresh minus 8 h. I cross-checked it against the host credentials file and the switcher's log.

    Pool invalid_grant Account Token left Who refreshed first Lead Effect
    10-19 10:18:20.229 A 60 s switcher, idle copy (inferred) — 158 invalid_grant in total over 13.4 h, repeated by the 5-minute background usage refresh; unusable about 14 h
    10-19 17:29:16.384 B expired 85 s before (a network error at 17:24:07 had delayed the first attempt) switcher, idle copy, 17:26:55 2 min 21 s 82 invalid_grant in total; unusable about 7 h
    10-20 14:13:06.588 C 76 s switcher, idle copy, 14:10:50 2 min 16 s about 3 min
    10-21 10:05:55.839 D 297 s Claude Code 2.1.306 in pool-routed thr_a2dvs6byg3, 10:05:55.121 0.72 s the request failed over to another account
    10-22 11:46:58.276 E (imported 94 min earlier) 268 s Claude Code 2.1.306 in pool-routed thr_biw5z6pxki, 11:46:57.727 0.55 s 429 in thr_biw5z6pxki: "Next account … 2026-10-22T12:29:59.646Z"
    10-23 00:49:50.876 B 296 s Claude Code 2.1.308 in pool-routed thr_cv4aev22bx, 00:49:50.440 0.44 s 429 in thr_cv4aev22bx, thr_cehnf3bqqh, thr_t9xt22j7id: "Next account … 2026-10-23T02:59:59.798Z"

    Counts. A "window" is one 8-hour expiry of one shared account, from 2026-10-19 08:49Z to 2026-10-23 01:25Z. Six shared Claude accounts give about 18 windows per day, 67 in total.

    • The pool refreshed first in 52 windows.
    • The host side refreshed first in 15: 8 by Claude Code on the active login and 7 by the switcher on idle copies.
    • 6 of those 15 produced the pool failures above, 3 from each source.

    Pool-routed bb threads refresh the host login. The original hypothesis assumed that nothing refreshes the local login once every session goes through the pool. That is not the case here.

    When the host loses.

    • Observed once directly, on 2026-10-23 while the sync script was paused: the pool refreshed account D at 01:45:07.248Z, and after that the switcher's copy of D failed with invalid_grant and was flagged dead.
    • For Claude Code, I read the behavior in the 2.1.309 binary. On invalid_grant it emits tengu_oauth_refresh_token_marked_dead_invalid_grant. If the stored refresh token is still the rejected one, it overwrites refreshToken, accessToken, and expiresAt with empty values (tengu_oauth_refresh_token_cleared_on_disk). That leaves the host in exactly this issue's "OAuth session expired and could not be refreshed" state.
    • I did not see a Claude Code wipe on this host during the window.

    Code (at 1e80ca7):

    What I ruled out

    Suggested priority and effort

    Medium priority. This hits anyone who imports a login that is still used on that machine, and that includes the bb server's own pool-routed Claude threads. Work stops when the losing pool account was the only eligible one, or when the host is signed out. Workaround: add the accounts with --login, remove the imported ones, and run claude /login on the host if its copy was already spent. Effort is low for the docs and the import warning, and medium for recording the account source.

    Investigation: bb thread thr_agudxusj98 (reports under pool-research/ in its thread storage). Affected threads: thr_a2dvs6byg3, thr_biw5z6pxki, thr_cv4aev22bx, thr_cehnf3bqqh, thr_t9xt22j7id.

    BB-Thread-ID: thr_agudxusj98

    AGENT GENERATED

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    partial-reproBug partially reproduced; some claims unverified; see linked reportpluginsPlugin SDK, runtime, marketplaceprovider-claude-codeBuilt-in plugin: provider-claude-codeprovidersCross-provider bridges, models, login

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions