fix(daemon): handle Anthropic usage 403 from tokens missing user:profile scope#294
Conversation
…ile scope The Anthropic OAuth usage endpoint (/api/oauth/usage) requires the user:profile scope. Tokens minted by `claude setup-token` (e.g. CLAUDE_CODE_OAUTH_TOKEN in CI) only carry user:inference, so the endpoint authenticates them but returns 403 "does not meet scope requirement user:profile". This is non-recoverable for that token, yet fetchRateLimit logged it at WARN every 10 min and kept re-calling the doomed endpoint. Surface the OAuth scopes (already parsed but unused) and add hasUsageScope(). fetchRateLimit now skips the call when user:profile is absent, and the post-fetch error branch mirrors codex_ratelimit.go: 403 -> "api:scope", 401 -> "api:401", both logged at Debug with a backoff so the daemon stops hammering. The statusline shows a clear "🚦 err:api:scope" instead of "🚦 err:api:403". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Codecov Report❌ Patch coverage is
❌ Your patch check has failed because the patch coverage (65.85%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage.
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 3 files with indirect coverage changes 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Code Review
This pull request introduces scope checking for Claude Code OAuth tokens to avoid unnecessary calls to the Anthropic usage endpoint when the token lacks the required user:profile scope (such as setup-tokens in CI). It updates credential parsing to extract scopes, skips fetching usage when the scope is missing, and handles 403 and 401 API errors with appropriate backoffs. The review feedback is highly constructive, pointing out that cached usage data should be cleared on scope, 403, and 401 errors to prevent displaying stale quota information. Additionally, the reviewer suggests a more idiomatic refactoring of the error-handling switch statement and a refinement to hasUsageScope to distinguish between nil and explicitly empty scope slices.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if !hasUsageScope(scopes) { | ||
| slog.Debug("Claude Code token lacks usage scope; skipping Anthropic usage fetch", | ||
| slog.String("required", anthropicUsageRequiredScope)) | ||
| s.rateLimitCache.mu.Lock() | ||
| s.rateLimitCache.lastError = "api:scope" | ||
| s.rateLimitCache.mu.Unlock() | ||
| return | ||
| } |
There was a problem hiding this comment.
When the token lacks the required usage scope, we set lastError to "api:scope" but do not clear the previously cached usage data. Because formatQuotaPart prioritizes displaying non-nil usage data over the error string, the statusline will continue to show stale, outdated quota information from the previous token instead of displaying the api:scope error.
To ensure the error is visible to the user, we should clear s.rateLimitCache.usage when a scope error is detected.
| if !hasUsageScope(scopes) { | |
| slog.Debug("Claude Code token lacks usage scope; skipping Anthropic usage fetch", | |
| slog.String("required", anthropicUsageRequiredScope)) | |
| s.rateLimitCache.mu.Lock() | |
| s.rateLimitCache.lastError = "api:scope" | |
| s.rateLimitCache.mu.Unlock() | |
| return | |
| } | |
| if !hasUsageScope(scopes) { | |
| slog.Debug("Claude Code token lacks usage scope; skipping Anthropic usage fetch", | |
| slog.String("required", anthropicUsageRequiredScope)) | |
| s.rateLimitCache.mu.Lock() | |
| s.rateLimitCache.lastError = "api:scope" | |
| s.rateLimitCache.usage = nil | |
| s.rateLimitCache.mu.Unlock() | |
| return | |
| } |
| var apiErr *anthropicAPIError | ||
| if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests { | ||
| switch { | ||
| case errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests: | ||
| // On rate limiting, back off longer than the normal TTL to avoid hammering the throttled | ||
| // bucket. Honor Retry-After when provided, otherwise use the default backoff. | ||
| slog.Warn("Failed to fetch Anthropic usage", slog.Any("err", err)) | ||
| s.rateLimitCache.lastError = shortenAPIError(err) | ||
| backoff := apiErr.RetryAfter | ||
| if backoff < anthropicRateLimitBackoff { | ||
| backoff = anthropicRateLimitBackoff | ||
| } | ||
| s.rateLimitCache.backoffUntil = time.Now().Add(backoff) | ||
| case errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden: | ||
| // 403 = token authenticated but lacks the usage scope (or org access). Expected for | ||
| // setup-tokens and non-recoverable for this token, so log quietly and back off. | ||
| slog.Debug("Anthropic usage forbidden for this token", slog.Any("err", err)) | ||
| s.rateLimitCache.lastError = "api:scope" | ||
| s.rateLimitCache.backoffUntil = time.Now().Add(anthropicRateLimitBackoff) | ||
| case errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized: | ||
| // 401 = expired/invalid token; won't self-heal until re-auth, so back off too. | ||
| slog.Debug("Anthropic usage unauthorized", slog.Any("err", err)) | ||
| s.rateLimitCache.lastError = "api:401" | ||
| s.rateLimitCache.backoffUntil = time.Now().Add(anthropicRateLimitBackoff) | ||
| default: | ||
| slog.Warn("Failed to fetch Anthropic usage", slog.Any("err", err)) | ||
| s.rateLimitCache.lastError = shortenAPIError(err) | ||
| } |
There was a problem hiding this comment.
Repeatedly calling errors.As(err, &apiErr) in each switch case is redundant and less idiomatic. Instead, we can call errors.As once to extract the *anthropicAPIError and then use a standard switch on apiErr.StatusCode.
Additionally, we should clear s.rateLimitCache.usage on StatusForbidden and StatusUnauthorized errors so that the statusline correctly displays the error instead of stale cached usage data.
var apiErr *anthropicAPIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case http.StatusTooManyRequests:
// On rate limiting, back off longer than the normal TTL to avoid hammering the throttled
// bucket. Honor Retry-After when provided, otherwise use the default backoff.
slog.Warn("Failed to fetch Anthropic usage", slog.Any("err", err))
s.rateLimitCache.lastError = shortenAPIError(err)
backoff := apiErr.RetryAfter
if backoff < anthropicRateLimitBackoff {
backoff = anthropicRateLimitBackoff
}
s.rateLimitCache.backoffUntil = time.Now().Add(backoff)
case http.StatusForbidden:
// 403 = token authenticated but lacks the usage scope (or org access). Expected for
// setup-tokens and non-recoverable for this token, so log quietly and back off.
slog.Debug("Anthropic usage forbidden for this token", slog.Any("err", err))
s.rateLimitCache.lastError = "api:scope"
s.rateLimitCache.usage = nil
s.rateLimitCache.backoffUntil = time.Now().Add(anthropicRateLimitBackoff)
case http.StatusUnauthorized:
// 401 = expired/invalid token; won't self-heal until re-auth, so back off too.
slog.Debug("Anthropic usage unauthorized", slog.Any("err", err))
s.rateLimitCache.lastError = "api:401"
s.rateLimitCache.usage = nil
s.rateLimitCache.backoffUntil = time.Now().Add(anthropicRateLimitBackoff)
default:
slog.Warn("Failed to fetch Anthropic usage", slog.Any("err", err))
s.rateLimitCache.lastError = shortenAPIError(err)
}
} else {
slog.Warn("Failed to fetch Anthropic usage", slog.Any("err", err))
s.rateLimitCache.lastError = shortenAPIError(err)
}| func hasUsageScope(scopes []string) bool { | ||
| if len(scopes) == 0 { | ||
| return true | ||
| } | ||
| for _, s := range scopes { | ||
| if s == anthropicUsageRequiredScope { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } |
There was a problem hiding this comment.
In Go, json.Unmarshal distinguishes between a missing field (which leaves the slice as nil) and an explicitly empty array [] (which initializes the slice as a non-nil empty slice []string{}).
Currently, len(scopes) == 0 returns true for both cases. However, if scopes is explicitly empty (scopes != nil), we know for sure that the token has no scopes granted, so we can safely return false and skip the API call immediately. We should only fallback to true when scopes is nil (unknown/older credentials format).
| func hasUsageScope(scopes []string) bool { | |
| if len(scopes) == 0 { | |
| return true | |
| } | |
| for _, s := range scopes { | |
| if s == anthropicUsageRequiredScope { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| func hasUsageScope(scopes []string) bool { | |
| if scopes == nil { | |
| return true | |
| } | |
| for _, s := range scopes { | |
| if s == anthropicUsageRequiredScope { | |
| return true | |
| } | |
| } | |
| return false | |
| } |
|
@claude review it |
|
Claude finished @AnnatarHe's task in 2m 1s —— View job Code Review
The fix is well-motivated and the two-layer approach (proactive scope skip + reactive 403/401 backoff) is clean. A few issues worth addressing before merging: Medium: Stale cached usage is never cleared on scope/auth errors
Proactive path ( if !hasUsageScope(scopes) {
slog.Debug(...)
s.rateLimitCache.mu.Lock()
s.rateLimitCache.lastError = "api:scope"
// ❌ s.rateLimitCache.usage is never cleared here
s.rateLimitCache.mu.Unlock()
return
}Reactive 403/401 path ( The fix Gemini suggested is correct: add Medium:
|
Problem
shelltime statuslinerunning in CI spams a recurring warning:Root cause: The Anthropic OAuth usage endpoint
GET https://api.anthropic.com/api/oauth/usagerequires the OAuth token to carry theuser:profilescope. Tokens minted byclaude setup-token— exactly whatclaude-code-review.ymlinjects viaclaude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}on the Linux runner — only carryuser:inference. The endpoint authenticates the token but forbids the usage read →403 "OAuth token does not meet scope requirement user:profile". Interactive Claude Code login tokens (~/.claude/.credentials.json) includeuser:profileand work fine.This is expected and non-recoverable for a setup-token, yet the old code logged it at
WARNevery 10 min and kept re-calling the doomed endpoint forever (only429had a backoff).Fix
Mirrors the existing
daemon/codex_ratelimit.go401/403 handling, in two layers:daemon/anthropic_ratelimit.go): surface thescopesfield (already parsed but unused) and addhasUsageScope().fetchRateLimitnow skips the API call entirely whenuser:profileis absent, settinglastError = "api:scope"atDebuglevel. Re-reads local creds each TTL, so it recovers within ~10 min if the user re-authenticates with a scoped token.daemon/cc_info_timer.go): if a403/401still comes back (scopes absent → tried anyway), log atDebug, map403 → api:scope/401 → api:401, and apply the existing 30-min backoff to stop hammering.429and other errors unchanged.The statusline needed no change —
formatQuotaPartalready renders any error via🚦 err:%s, so it now shows a clear🚦 err:api:scopeinstead of the crypticerr:api:403.Net effect in CI
No more
WARN "Failed to fetch Anthropic usage", no repeated doomed calls, and a clear🚦 err:api:scope. With a real interactive login token, quota (🚦 5h:..% 7d:..%) displays as before.Tests
TestHasUsageScope, scope-awareparseOAuthTokenFromJSONassertion,TestFormatQuotaPart_WithScopeError, and linuxfetchRateLimitcases for missing-scope skip + reactive-403 backoff.GOOS=linux go vet ./daemon/passes. Pre-existingbind: invalid argumentsocket-path test failures on macOS are unaffected (identical failure set on cleanHEAD).🤖 Generated with Claude Code