Re-review caught the predicate being wrong. Refresh wrapped ErrExpired for every non-200, and Source latched ErrExpired as "this login is dead" — so a 502, a 503, or a 429 permanently disabled refresh for the process and reported "your Claude Code login has expired" about a login that was fine. That is worst for 429, which is the only response this endpoint has ever produced. The realistic first production refresh would have killed auth for the whole session with a misleading message. Now only 400/401/403 wrap ErrExpired and get remembered. Everything else reports what happened and is retried on the next request. Being wrong in the direction of asking again costs a round trip; being wrong the other way costs the session. The remembered refusal also keeps the server's reason rather than collapsing into a bare ErrExpired, so the second and third failures say as much as the first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing 3 changed files with 173 additions and 22 deletions.
| @@ -221,9 +221,17 @@ Four more things the write has to survive, none of them hypothetical: | ||
| 221 | 221 | meaning of a field Claude Code relies on; keep the old token's expiry beside |
| 222 | 222 | a rotated token and our own next startup reads it, decides the refresh token |
| 223 | 223 | is dead, and refuses to send a request the server would have honoured. |
| 224 | - Instead `Source` asks once and remembers the answer — a refusal sets a flag | |
| 225 | - and every later call returns `ErrExpired` without another round trip. An | |
| 226 | - answer from the server beats a timestamp we would have had to invent. | |
| 224 | + Instead `Source` asks once and remembers the answer. An answer from the | |
| 225 | + server beats a timestamp we would have had to invent. | |
| 226 | + | |
| 227 | + What counts as an answer is the load-bearing part. Only 400/401/403 — the | |
| 228 | + server saying this grant is no good — wrap `ErrExpired` and get remembered. | |
| 229 | + A 429 or a 5xx does not, because latching one would disable refresh for the | |
| 230 | + rest of the session on a login that is fine. 429 is precisely where that | |
| 231 | + would bite: it is the only response this endpoint has ever given us, and we | |
| 232 | + still do not know whether it means "slow down" or "no". Being wrong in the | |
| 233 | + direction of asking again is cheap; being wrong the other way costs the user | |
| 234 | + their session. | |
| 227 | 235 | |
| 228 | 236 | Because the endpoint has never once answered, none of this is verified against |
| 229 | 237 | the live service. It is verified against `httptest` and against the real file's |
| @@ -58,12 +58,18 @@ var ErrExpired = errors.New("claudecode: your Claude Code login has expired, run | ||
| 58 | 58 | // credentials and writes nothing — persisting is a separate decision, made by |
| 59 | 59 | // Source, because whether it is safe depends on whether the server rotated. |
| 60 | 60 | // |
| 61 | - // A refusal wraps ErrExpired, so callers can classify it with errors.Is rather | |
| 62 | - // than by reading status codes. That includes a 429: on these OAuth surfaces a | |
| 63 | - // 429 is not reliable evidence of a rate limit — the Messages API answers a | |
| 64 | - // request shape it declines the same way, and every probe of this endpoint has | |
| 65 | - // returned one without ever completing an exchange — so advising someone to | |
| 66 | - // wait would be advice that may never come good. | |
| 61 | + // A refusal — the server saying this grant is no good — wraps ErrExpired, so | |
| 62 | + // callers can classify it with errors.Is rather than by reading status codes | |
| 63 | + // back out of a string. Only 400/401/403 mean that. | |
| 64 | + // | |
| 65 | + // Everything else, 429 and 5xx included, is reported as an ordinary failure. | |
| 66 | + // The distinction is load-bearing: the caller remembers a refusal and stops | |
| 67 | + // asking, so treating a transient failure as one would disable refresh for the | |
| 68 | + // rest of the session on a login that is perfectly fine. 429 is exactly where | |
| 69 | + // that would bite, because it is the only response this endpoint has ever | |
| 70 | + // given us — and we still do not know whether it means "slow down" or "no". | |
| 71 | + // Being wrong in the direction of asking again is cheap; being wrong the other | |
| 72 | + // way costs the user their session. | |
| 67 | 73 | func Refresh(ctx context.Context, h *http.Client, c *Credentials) (*Credentials, error) { |
| 68 | 74 | ctx, cancel := context.WithTimeout(ctx, refreshTimeout) |
| 69 | 75 | defer cancel() |
| @@ -91,8 +97,14 @@ func Refresh(ctx context.Context, h *http.Client, c *Credentials) (*Credentials, | ||
| 91 | 97 | if err != nil { |
| 92 | 98 | return nil, fmt.Errorf("claudecode: reading refresh response: %w", err) |
| 93 | 99 | } |
| 94 | - if resp.StatusCode != http.StatusOK { | |
| 95 | - return nil, fmt.Errorf("%w (refresh refused: %d %s)", ErrExpired, resp.StatusCode, clip(raw)) | |
| 100 | + switch resp.StatusCode { | |
| 101 | + case http.StatusOK: | |
| 102 | + case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden: | |
| 103 | + return nil, fmt.Errorf("%w (the server refused the refresh token: %d %s)", | |
| 104 | + ErrExpired, resp.StatusCode, clip(raw)) | |
| 105 | + default: | |
| 106 | + return nil, fmt.Errorf("claudecode: refresh failed (%d %s); if it keeps happening, run `claude` to log in again", | |
| 107 | + resp.StatusCode, clip(raw)) | |
| 96 | 108 | } |
| 97 | 109 | |
| 98 | 110 | var out struct { |
| @@ -259,12 +271,14 @@ type Source struct { | ||
| 259 | 271 | // home is where a rotated credential is written back. Empty disables |
| 260 | 272 | // write-back entirely. |
| 261 | 273 | home string |
| 262 | - // refused records that the server has already rejected this refresh token. | |
| 263 | - // There is no stored refresh-token expiry to consult — the file's is not | |
| 264 | - // updated when a token rotates, so trusting it would refuse a request the | |
| 265 | - // server would have honoured. Asking once and remembering the answer is | |
| 266 | - // both more accurate and cheaper than guessing from a timestamp. | |
| 267 | - refused bool | |
| 274 | + // refused holds the refusal the server gave us, if it gave one. There is no | |
| 275 | + // stored refresh-token expiry to consult — the file's is not updated when a | |
| 276 | + // token rotates, so trusting it would refuse a request the server would | |
| 277 | + // have honoured. Asking once and remembering the answer is both more | |
| 278 | + // accurate and cheaper than guessing from a timestamp. The error itself is | |
| 279 | + // kept rather than a bare flag, so the reason survives to whoever asks | |
| 280 | + // next instead of collapsing into an unexplained "expired". | |
| 281 | + refused error | |
| 268 | 282 | |
| 269 | 283 | // OnWriteBackFailure is called when the server rotated the refresh token |
| 270 | 284 | // but the new one could not be stored. That combination is the one this |
| @@ -308,15 +322,21 @@ func (s *Source) Token(ctx context.Context) (string, error) { | ||
| 308 | 322 | // Once the server has refused this refresh token, it will refuse it again. |
| 309 | 323 | // Remembering that is what keeps a dead login from spending a round trip |
| 310 | 324 | // on every subsequent request. |
| 311 | - if s.refused || s.creds.RefreshToken == "" { | |
| 325 | + if s.refused != nil { | |
| 326 | + return "", s.refused | |
| 327 | + } | |
| 328 | + if s.creds.RefreshToken == "" { | |
| 312 | 329 | return "", ErrExpired |
| 313 | 330 | } |
| 314 | 331 | |
| 315 | 332 | from := s.creds.RefreshToken |
| 316 | 333 | next, err := Refresh(ctx, s.http, s.creds) |
| 317 | 334 | if err != nil { |
| 335 | + // Only a refusal is remembered. A timeout, a 5xx, a 429 — those say | |
| 336 | + // nothing about whether the token is good, and latching them would | |
| 337 | + // turn a blip into a dead session. | |
| 318 | 338 | if errors.Is(err, ErrExpired) { |
| 319 | - s.refused = true | |
| 339 | + s.refused = err | |
| 320 | 340 | } |
| 321 | 341 | return "", err |
| 322 | 342 | } |
| @@ -85,9 +85,11 @@ func TestRefreshRejectedSaysToLogInAgain(t *testing.T) { | ||
| 85 | 85 | } |
| 86 | 86 | } |
| 87 | 87 | |
| 88 | - // A 429 from these OAuth surfaces is not reliable evidence of a rate limit: | |
| 89 | - // the Messages API answers a request shape it declines the same way. Telling | |
| 90 | - // the user to wait would be advice that never comes good. | |
| 88 | + // A 429 from these OAuth surfaces is not reliable evidence of a rate limit — | |
| 89 | + // the Messages API answers a request shape it declines the same way — so the | |
| 90 | + // advice points at `claude` rather than at waiting. But it is equally not | |
| 91 | + // evidence of a refusal, so it must not wrap ErrExpired: that is what stops | |
| 92 | + // the caller latching it and disabling refresh for the session. | |
| 91 | 93 | func TestRefreshRateLimitedDoesNotAdviseWaiting(t *testing.T) { |
| 92 | 94 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 93 | 95 | w.WriteHeader(http.StatusTooManyRequests) |
| @@ -103,6 +105,9 @@ func TestRefreshRateLimitedDoesNotAdviseWaiting(t *testing.T) { | ||
| 103 | 105 | if !strings.Contains(err.Error(), "claude") { |
| 104 | 106 | t.Errorf("a 429 should still point at `claude`, got %q", err) |
| 105 | 107 | } |
| 108 | + if errors.Is(err, ErrExpired) { | |
| 109 | + t.Errorf("a 429 must not be classified as a refusal, or the source latches it: %v", err) | |
| 110 | + } | |
| 106 | 111 | } |
| 107 | 112 | |
| 108 | 113 | func TestRefreshTransportErrorIsReported(t *testing.T) { |
| @@ -623,3 +628,121 @@ func TestRefreshErrorDoesNotDumpTheWholeBody(t *testing.T) { | ||
| 623 | 628 | t.Errorf("error carried %d bytes of body; it should be clipped to %d", strings.Count(err.Error(), "Z"), maxErrorBody) |
| 624 | 629 | } |
| 625 | 630 | } |
| 631 | + | |
| 632 | + // A 429 is the only response this endpoint has ever produced, and we still do | |
| 633 | + // not know whether it means "slow down" or "no". Latching it would disable | |
| 634 | + // refresh for the rest of the session on a login that is fine, so it must not | |
| 635 | + // be remembered as a refusal. | |
| 636 | + func TestTransientFailuresDoNotPoisonTheSource(t *testing.T) { | |
| 637 | + for _, status := range []int{ | |
| 638 | + http.StatusTooManyRequests, | |
| 639 | + http.StatusInternalServerError, | |
| 640 | + http.StatusBadGateway, | |
| 641 | + http.StatusServiceUnavailable, | |
| 642 | + } { | |
| 643 | + t.Run(http.StatusText(status), func(t *testing.T) { | |
| 644 | + var hits int | |
| 645 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 646 | + hits++ | |
| 647 | + if hits == 1 { | |
| 648 | + w.WriteHeader(status) | |
| 649 | + io.WriteString(w, `{"error":"transient"}`) | |
| 650 | + return | |
| 651 | + } | |
| 652 | + io.WriteString(w, `{"access_token":"fresh","refresh_token":"r1","expires_in":28800}`) | |
| 653 | + })) | |
| 654 | + defer srv.Close() | |
| 655 | + t.Setenv(tokenURLEnv, srv.URL) | |
| 656 | + | |
| 657 | + s := NewSource(liveCreds(), srv.Client(), "") | |
| 658 | + if _, err := s.Token(context.Background()); err == nil { | |
| 659 | + t.Fatal("the first call should surface the failure") | |
| 660 | + } else if errors.Is(err, ErrExpired) { | |
| 661 | + t.Errorf("a %d must not be reported as an expired login: %v", status, err) | |
| 662 | + } | |
| 663 | + // The server recovers; so must we. | |
| 664 | + tok, err := s.Token(context.Background()) | |
| 665 | + if err != nil { | |
| 666 | + t.Fatalf("the source latched a transient failure: %v", err) | |
| 667 | + } | |
| 668 | + if tok != "fresh" { | |
| 669 | + t.Errorf("Token = %q, want fresh", tok) | |
| 670 | + } | |
| 671 | + }) | |
| 672 | + } | |
| 673 | + } | |
| 674 | + | |
| 675 | + // A real refusal, by contrast, is remembered — and the reason survives with it | |
| 676 | + // rather than collapsing into an unexplained "expired". | |
| 677 | + func TestRefusalIsRememberedWithItsReason(t *testing.T) { | |
| 678 | + for _, status := range []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden} { | |
| 679 | + t.Run(http.StatusText(status), func(t *testing.T) { | |
| 680 | + var hits int | |
| 681 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 682 | + hits++ | |
| 683 | + w.WriteHeader(status) | |
| 684 | + io.WriteString(w, `{"error":"invalid_grant"}`) | |
| 685 | + })) | |
| 686 | + defer srv.Close() | |
| 687 | + t.Setenv(tokenURLEnv, srv.URL) | |
| 688 | + | |
| 689 | + s := NewSource(liveCreds(), srv.Client(), "") | |
| 690 | + for i := 0; i < 3; i++ { | |
| 691 | + _, err := s.Token(context.Background()) | |
| 692 | + if !errors.Is(err, ErrExpired) { | |
| 693 | + t.Fatalf("call %d: err = %v, want ErrExpired", i, err) | |
| 694 | + } | |
| 695 | + if !strings.Contains(err.Error(), "invalid_grant") { | |
| 696 | + t.Errorf("call %d lost the reason: %v", i, err) | |
| 697 | + } | |
| 698 | + } | |
| 699 | + if hits != 1 { | |
| 700 | + t.Errorf("asked the server %d times, want 1", hits) | |
| 701 | + } | |
| 702 | + }) | |
| 703 | + } | |
| 704 | + } | |
| 705 | + | |
| 706 | + // Someone else writing a newer credential is good news, not a broken login, so | |
| 707 | + // it must not reach the warning hook. | |
| 708 | + func TestStoreChangedIsNotReportedAsAFailure(t *testing.T) { | |
| 709 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 710 | + io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2-rotated","expires_in":28800}`) | |
| 711 | + })) | |
| 712 | + defer srv.Close() | |
| 713 | + t.Setenv(tokenURLEnv, srv.URL) | |
| 714 | + | |
| 715 | + // The store holds a token that is not the one we refreshed from. | |
| 716 | + home := storeWith(t, "somebody-elses-newer-token") | |
| 717 | + s := NewSource(liveCreds(), srv.Client(), home) | |
| 718 | + var reported error | |
| 719 | + s.OnWriteBackFailure = func(err error) { reported = err } | |
| 720 | + | |
| 721 | + if _, err := s.Token(context.Background()); err != nil { | |
| 722 | + t.Fatalf("Token: %v", err) | |
| 723 | + } | |
| 724 | + if reported != nil { | |
| 725 | + t.Errorf("a newer credential from someone else was reported as a failure: %v", reported) | |
| 726 | + } | |
| 727 | + } | |
| 728 | + | |
| 729 | + // The design now hinges on refreshTokenExpiresAt surviving as a key we do not | |
| 730 | + // model, so it gets a named guard rather than riding on another test. | |
| 731 | + func TestSavePreservesTheRefreshExpiryKeyThroughRotation(t *testing.T) { | |
| 732 | + home := storeWith(t, "r1") | |
| 733 | + c, err := Load(home) | |
| 734 | + if err != nil { | |
| 735 | + t.Fatal(err) | |
| 736 | + } | |
| 737 | + c.RefreshToken = "r2-rotated" | |
| 738 | + if err := Save(home, c, "r1"); err != nil { | |
| 739 | + t.Fatalf("Save: %v", err) | |
| 740 | + } | |
| 741 | + oauth := readStore(t, home)["claudeAiOauth"].(map[string]any) | |
| 742 | + if _, ok := oauth["refreshTokenExpiresAt"]; !ok { | |
| 743 | + t.Error("refreshTokenExpiresAt vanished; a key we do not model is not ours to drop") | |
| 744 | + } | |
| 745 | + if oauth["refreshToken"] != "r2-rotated" { | |
| 746 | + t.Errorf("refreshToken = %v, want the rotated one", oauth["refreshToken"]) | |
| 747 | + } | |
| 748 | + } | |