Skip to content

fix(daemon): handle Anthropic usage 403 from tokens missing user:profile scope#294

Merged
AnnatarHe merged 1 commit into
mainfrom
fix/anthropic-usage-403-scope
Jun 22, 2026
Merged

fix(daemon): handle Anthropic usage 403 from tokens missing user:profile scope#294
AnnatarHe merged 1 commit into
mainfrom
fix/anthropic-usage-403-scope

Conversation

@AnnatarHe

Copy link
Copy Markdown
Contributor

Problem

shelltime statusline running in CI spams a recurring warning:

level=WARN source=daemon/cc_info_timer.go:438 msg="Failed to fetch Anthropic usage" err="anthropic usage API returned status 403"

Root cause: The Anthropic OAuth usage endpoint GET https://api.anthropic.com/api/oauth/usage requires the OAuth token to carry the user:profile scope. Tokens minted by claude setup-token — exactly what claude-code-review.yml injects via claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} on the Linux runner — only carry user: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) include user:profile and work fine.

This is expected and non-recoverable for a setup-token, yet the old code logged it at WARN every 10 min and kept re-calling the doomed endpoint forever (only 429 had a backoff).

Fix

Mirrors the existing daemon/codex_ratelimit.go 401/403 handling, in two layers:

  1. Proactive (daemon/anthropic_ratelimit.go): surface the scopes field (already parsed but unused) and add hasUsageScope(). fetchRateLimit now skips the API call entirely when user:profile is absent, setting lastError = "api:scope" at Debug level. Re-reads local creds each TTL, so it recovers within ~10 min if the user re-authenticates with a scoped token.
  2. Reactive (daemon/cc_info_timer.go): if a 403/401 still comes back (scopes absent → tried anyway), log at Debug, map 403 → api:scope / 401 → api:401, and apply the existing 30-min backoff to stop hammering. 429 and other errors unchanged.

The statusline needed no change — formatQuotaPart already renders any error via 🚦 err:%s, so it now shows a clear 🚦 err:api:scope instead of the cryptic err: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

  • New: TestHasUsageScope, scope-aware parseOAuthTokenFromJSON assertion, TestFormatQuotaPart_WithScopeError, and linux fetchRateLimit cases for missing-scope skip + reactive-403 backoff.
  • Both binaries build; GOOS=linux go vet ./daemon/ passes. Pre-existing bind: invalid argument socket-path test failures on macOS are unaffected (identical failure set on clean HEAD).

🤖 Generated with Claude Code

…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>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@codecov

codecov Bot commented Jun 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 65.85366% with 14 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
daemon/cc_info_timer.go 56.52% 10 Missing ⚠️
daemon/anthropic_ratelimit.go 77.77% 4 Missing ⚠️

❌ 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.
❌ Your project check has failed because the head coverage (78.94%) is below the target coverage (80.00%). You can increase the head coverage or adjust the target coverage.

Flag Coverage Δ
unittests 78.94% <65.85%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
daemon/anthropic_ratelimit.go 76.38% <77.77%> (+2.54%) ⬆️
daemon/cc_info_timer.go 87.28% <56.52%> (+0.43%) ⬆️

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@gemini-code-assist gemini-code-assist Bot 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.

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.

Comment thread daemon/cc_info_timer.go
Comment on lines +439 to +446
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
}

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.

medium

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.

Suggested change
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
}

Comment thread daemon/cc_info_timer.go
Comment on lines 451 to 477
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)
}

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.

medium

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)
		}

Comment on lines +148 to 158
func hasUsageScope(scopes []string) bool {
if len(scopes) == 0 {
return true
}
for _, s := range scopes {
if s == anthropicUsageRequiredScope {
return true
}
}
return false
}

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.

medium

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).

Suggested change
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
}

@AnnatarHe

Copy link
Copy Markdown
Contributor Author

@claude review it

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @AnnatarHe's task in 2m 1s —— View job


Code Review

  • Gather context
  • Read changed files
  • Analyze code changes
  • Post review feedback

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

formatQuotaPart prioritizes non-nil usage over the lastError string (confirmed by TestFormatQuotaPart_ErrorIgnoredWhenDataPresent). This means if a user previously authenticated with a scoped token (so usage was cached), and then switches to a CI setup-token, the cached quota data will display indefinitely instead of the 🚦 err:api:scope indicator.

Proactive path (cc_info_timer.go:439-446):

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 (cc_info_timer.go:463-473): same omission — usage is not set to nil on 403 or 401.

The fix Gemini suggested is correct: add s.rateLimitCache.usage = nil to both the proactive path and the 403/401 reactive cases. Fix this →


Medium: hasUsageScope treats nil and []string{} identically

cc_info_timer.go:148-158:

func hasUsageScope(scopes []string) bool {
    if len(scopes) == 0 {  // true for BOTH nil and []string{}
        return true
    }
    ...
}

In Go, json.Unmarshal distinguishes the two:

  • nil"scopes" field absent in JSON (older credential format) → fall through to fetch, let reactive 403 decide ✓
  • []string{}"scopes": [] in JSON → the server said "no scopes" explicitly → should return false and skip

The fix Gemini suggested is right:

func hasUsageScope(scopes []string) bool {
    if scopes == nil {
        return true  // unknown format, attempt fetch
    }
    for _, s := range scopes {
        if s == anthropicUsageRequiredScope {
            return true
        }
    }
    return false
}

The existing TestHasUsageScope test only covers nil, not []string{} — a test case should be added for the explicit empty slice. Fix this →


Low: errors.As called redundantly in each switch case

cc_info_timer.go:452-477:

var apiErr *anthropicAPIError
switch {
case errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests:
    ...
case errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden:
    ...
case errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized:

errors.As is called (and re-assigns apiErr) on each case. The idiomatic Go approach is one errors.As call + switch on the status code:

var apiErr *anthropicAPIError
if errors.As(err, &apiErr) {
    switch apiErr.StatusCode {
    case http.StatusTooManyRequests: ...
    case http.StatusForbidden: ...
    case http.StatusUnauthorized: ...
    }
} else {
    // non-API error
}

This is functionally correct as written, but the refactor makes the logic clearer.


Minor: Missing test for 401 reactive path

TestFetchRateLimit_Forbidden403SetsScopeError covers the reactive 403 case. The 401 case (api:401 error + 30-min backoff) has no corresponding integration test.


What looks good

  • The dual-layer design (proactive skip + reactive backoff) is clean and mirrors the existing codex pattern.
  • Re-reading credentials each TTL (no scope caching) is correct — allows recovery after re-auth without restart.
  • 429 handling is unchanged and still honors Retry-After.
  • anthropicUsageRequiredScope constant avoids magic strings.
  • Test coverage for the new happy-path scope check (TestFetchRateLimit_MissingScopeSkipsFetch) properly verifies the API is never called.
  • TestFormatQuotaPart_WithScopeError ensures the statusline renders 🚦 err:api:scope correctly.

@AnnatarHe AnnatarHe merged commit fa744a5 into main Jun 22, 2026
6 of 8 checks passed
@AnnatarHe AnnatarHe deleted the fix/anthropic-usage-403-scope branch June 22, 2026 16:52
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.

1 participant