Skip to content

autoclaw: proactively refresh token on 401/403 before failing - #219

Open
Euraxluo wants to merge 2 commits into
magpie-community:mainfrom
Euraxluo:fix/autoclaw-reactive-refresh-401
Open

Euraxluo wants to merge 2 commits into
magpie-community:mainfrom
Euraxluo:fix/autoclaw-reactive-refresh-401

Conversation

@Euraxluo

@Euraxluo Euraxluo commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Rebased onto main (autoclaw 0.2.21) and reshaped per the review of 578da1e. The plugin now renews the access token reactively on a double-401, safely.

What the rebased branch does

  • 401 on both hosts → one renewal → retry both hosts (autoclaw: loader.fetch should proactively refresh token on 401/403 before failing (prevents infinite backoff with stale access_token) #217). The host only renews on expires; a token revoked upstream used to put the account into backoff forever.
  • Single-flight renewal (review point 3b): concurrent requests that all get a 401 share one in-flight refresh promise per loader. The refresh token rotates, so a second concurrent refresh would spend the first one's token and lose one of the writes.
  • Save before swap (review point 3a): the renewed credentials go through client.auth.set() and the save is awaited BEFORE the in-memory token is replaced. If the save fails, the new tokens are not used for the request: the reply is a 401 with X-Magpie-Sign-In: expired and the reason in the message. Running on tokens that the next start could never reproduce would sign the account out for good.
  • signIn passthrough (review point 3c): refreshTokens throws signIn: "kept" for a banned account and signIn: "expired" for a dead grant; tryRenew catches and records which one happened, and the final 401's X-Magpie-Sign-In and message reflect it (banned keeps the sign-in, message carries the vendor's words).
  • The 403-quota branch in explained is dropped (review point 2): main's quotaRefusal already turns a 403 810000/quota-words reply into a 429 rate_limit_error with X-Magpie-Sign-In: kept. No second reading of the same reply. The README's 403 lines are gone with it.
  • README (review point 5): the wallet-tier section is dropped; one line in 已知行为 says what renewal does, including kept vs expired.
  • Version: 0.2.22 (main is at 0.2.21).

Tests (review point 4)

HOME=$(mktemp -d) bun test packages/autoclaw: 115 pass, 0 fail. bun scripts/check.mjs autoclaw: ok.

Three new tests, each verified red with the change taken out (mutation-tested):

mutation (change removed) test that goes red
single-flight removed (every 401 starts its own refresh) concurrent 401s refresh once (single-flight), both requests succeed — refreshCalls becomes 2
failed auth.set swallowed, new tokens used anyway a failed auth.set keeps the new token unused and answers expired
signIn not passed through (ban becomes expired) a banned refresh surfaces kept, not expired

Fixes #217

🤖 Generated with Claude Code

@yetone yetone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Euraxluo, and sorry this waited so long. I reviewed 578da1ea. Nothing in it reaches past its goal (no new hosts, the refresh goes to AutoClaw's own /userapi/v1/refresh). With a sandbox HOME, bun test in packages/autoclaw at your head gives 25 pass, 0 fail. It can't merge as it is:

  1. It conflicts with main in packages/autoclaw/index.mjs, index.test.mjs and README.md. main is at autoclaw 0.2.12 since this was opened (#355, #367, #376, #437, #445, #466). Please rebase and bump to 0.2.13 (package.json isn't bumped here).

  2. The 403 quota half is already on main, differently. quotaRefusal (main packages/autoclaw/index.mjs:143-175, plugins#274) turns a 403 810000 / quota-words reply into a 429 rate_limit_error with X-Magpie-Sign-In: kept, so the gateway moves on to the next candidate. A banned account's 401/403 (accountRefusal, bannedSaid) is kept too. Please drop the 403 branch in explained (index.mjs:105-127 at your head) and the README's 403 lines (README.md:86-99, README.md:101), so there aren't two readings of the same reply.

  3. Renewing on a 401 is still missing on main, so #217 stands, and the host only renews by expires. The renewal at index.mjs:598-611 needs two fixes before it is safe, because AutoClaw rotates the refresh token (the README's "共用会互踢"):

    • client?.auth?.set?.(…).catch?.(() => {}) (index.mjs:605) swallows a failed save. The old refresh token is already spent, so the next start has nothing that works, and the account is signed out for good. If the save fails, don't use the new tokens for this request; say so (X-Magpie-Sign-In: expired with the reason) rather than going on.
    • Concurrent requests that all get a 401 each call tryRenew. The second refresh spends a token the first already rotated, and one of the writes is lost. Keep one renewal in flight per account (a shared promise), and have the others wait for it.
    • On main, refreshTokens (main index.mjs:799-826) throws signIn: "kept" for a banned account and signIn: "expired" only for a dead grant. tryRenew returns false on any throw, so a ban becomes the "登录已失效" 401 with expired. Pass the refresh's own signIn through.
  4. Tests: please add a case for two concurrent 401s that refresh once, one where auth.set rejects, and one where the refresh says banned. Check that each is red with the change taken out, and say so in the PR.

  5. Please keep the README to what the plugin does. Lines 86-99 ("钱包隔离与会员准入分级", which wallet a model can spend) come from your reading of the wallets API. They can't be checked from here, and they'll go stale. A sentence saying a spent model quota moves to the next candidate and keeps the sign-in is enough.

Euraxluo and others added 2 commits November 10, 2026 21:11
…ustion 403

- Proactively refresh access token via refreshTokens() and update storage
  when upstream answers 401 Unauthorized before declaring sign-in expired.
- Distinguish HTTP 403 quota exhaustion (code 810000 / quota used up) from
  auth expiration, returning insufficient_quota and keeping X-Magpie-Sign-In: kept.
- Document 5.3 vs 5.3 Flash quota tiering and usage notes in README.md.

Fixes magpie-community#217

Co-Authored-By: Claude Code <noreply@anthropic.com>
…passthrough

Rebased onto upstream/main (0.2.21). Addresses the review of magpie-community#219:

- 401 on both hosts renews the token once and retries both hosts
  (magpie-community#217); concurrent 401s share one in-flight refresh — the refresh
  token rotates, so a second concurrent refresh would spend the first
  one's token and lose one of the writes (test: concurrent-401s)
- the new tokens are saved through client.auth.set BEFORE they are
  used: a failed save answers expired with the reason instead of
  running on tokens the next start could never reproduce (test:
  failed-auth.set)
- a refresh that comes back banned passes signIn "kept" through, so a
  banned account is not signed out (test: banned-refresh)
- the 403-quota branch in explained is gone — main's quotaRefusal
  already turns a spent quota into 429 with X-Magpie-Sign-In: kept
- README: the wallet-tier section is dropped (unverifiable, goes
  stale); one line on renewal covers it

Tests: HOME=$(mktemp -d) bun test packages/autoclaw — 115 pass, 0 fail.
Mutations: single-flight removed → concurrent test red; auth.set
failure swallowed → failed-save test red; signIn not passed → banned
test red.
@Euraxluo
Euraxluo force-pushed the fix/autoclaw-reactive-refresh-401 branch from 578da1e to ca5e2d4 Compare November 10, 2026 13:16
@Euraxluo

Copy link
Copy Markdown
Contributor Author

Rebased onto main (0.2.21) and pushed as ca5e2d4 — all five review points addressed:

  1. Conflict with main — rebased; package.json bumped to 0.2.22 (main is 0.2.21).
  2. 403-quota half dropped — the explained 403 branch and the README's 403 lines are gone. main's quotaRefusal (810000/quota-words → 429 rate_limit_error, X-Magpie-Sign-In: kept) is the single reading now; the double-host loop asks refusedNotSignIn (which wraps both quotaRefusal and accountRefusal) on every 401/403 before anything else.
  3. Renewal fixed per 3a/3b/3c:
    • 3a: client.auth.set() is awaited BEFORE the in-memory tokens are swapped. A failed save answers 401 X-Magpie-Sign-In: expired with the save error in the message — the new tokens are not used for the request (the old refresh token is already spent, so the reply says why the next start would have nothing).
    • 3b: one in-flight refresh promise per loader closure; concurrent 401s await it. Second renewal cannot spend the first one's rotated token.
    • 3c: tryRenew catches refreshTokens' throw and records e.signIn; the final reply's X-Magpie-Sign-In is kept (banned, vendor's words in the message) or expired (dead grant), never collapsing a ban into an expiry.
  4. Tests added, each mutation-verified red with the change removed:
    • concurrent 401s refresh once (single-flight), both requests succeed — without the in-flight gate, /userapi/v1/refresh is hit twice and the test's refreshCalls.length check fails (verified: 2 ≠ 1).
    • a failed auth.set keeps the new token unused and answers expired — with the save swallowed and the swap done first, the retry goes out with Bearer fresh and the test's chatAuth.every(t => t !== "Bearer fresh") fails (verified).
    • a banned refresh surfaces kept, not expired — with the passthrough collapsed to return false + generic message, the header check kept fails (verified).
      Full suite at ca5e2d4: HOME=$(mktemp -d) bun test packages/autoclaw → 115 pass, 0 fail; bun scripts/check.mjs autoclaw → ok.
  5. README — the wallet-tier section (and the 403 lines) are deleted; 已知行为 now carries one line: both-hosts-401 renews once and retries, renewed on success, kept-vs-expired semantics on failure.

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.

autoclaw: loader.fetch should proactively refresh token on 401/403 before failing (prevents infinite backoff with stale access_token)

2 participants