Documentation
¶
Overview ¶
Package ratelimit - claude_backoff.go decides how long to stay away from the Claude Code subscription usage API after a failed fetch.
Motivation (Task 20326): the dashboard was observed calling /api/claudecode-limits about once a second, and the caps panel was reporting "Rate limited. Please try again later." The two were connected, but not in the obvious way — the handler caches for MinUsageCacheTTL, so the browser's polling could not by itself reach the upstream API more than once a minute.
The connection was here. A failed fetch does not refresh the snapshot, so the *only* thing standing between a caller and another upstream request is this backoff, and it was a flat 30s — half of MinUsageCacheTTL. So a hub that was healthy asked once a minute, and a hub that had just been told "too many requests" asked twice a minute. Being rate-limited made cloop press harder, which is exactly the self-sustaining loop the old comment on transientBackoff said it existed to prevent.
The rule this file encodes: a rate-limit response must never be retried sooner than a success would have been re-fetched, it must honour Retry-After when the server sends one, and repeated refusals must escalate rather than settle into a fixed drumbeat.
Package ratelimit - claude_limits.go enforces per-project caps on the global Claude Code subscription utilization (5-hour and weekly windows).
Package ratelimit - claude_reauth.go classifies *why* the Claude Code subscription usage API cannot be reached, so a stalled caps panel says "re-authenticate" instead of going quietly blank.
Motivation (Task 20202): this hub's caps stopped updating for four days. The OAuth access token had expired and ~/.claude/.credentials.json carried an empty refreshToken (the shape `claude setup-token` writes), so the token resolver bailed out with a bare "no OAuth token available". Every consumer discarded that string: the UI handler dropped the error on the floor, the panel rendered four "not reported" rows, and the orchestrator's cap enforcement silently degraded to a no-op. Nothing anywhere said the words "log in again".
The fix is to make the failure a typed, classified, cached value that the API and the dashboard can both render, with a concrete recovery hint.
Package ratelimit - claude_refresh.go exposes this package's OAuth credential refresh to callers outside it.
Motivation (Task 20204): the `claude` CLI subprocesses spawned by pkg/provider/claudecode each manage their own OAuth refresh, entirely outside the single-flight locks maintained here. Claude.ai refresh tokens are single-use and rotate on every exchange, so when a parallel plan runs several tasks at once and the access token lapses mid-round, multiple CLI processes POST the same refresh token concurrently: one wins, the losers get invalid_grant and their task dies with "API Error: 401". The following task succeeds because by then the winner has written a fresh credential to disk — which is exactly the reported "sometimes 401, next task is fine" symptom.
A provider that hits a 401 therefore wants to re-read — and, only if the credential is genuinely stale, rotate — the token under *this* package's locks before retrying, rather than immediately re-sending the credential the server just rejected.
Package ratelimit - claude_usage.go fetches Claude Code subscription usage from the Anthropic OAuth usage API endpoint.
Package ratelimit captures and exposes Anthropic API rate-limit information from anthropic-ratelimit-* response headers.
On every Anthropic API call, callers invoke Record(model, headers). The package parses recognised headers (RPM, ITPM, OTPM, weekly/5h windows, priority tier) and stores a per-model snapshot in memory. Snapshots are also persisted to ~/.cloop/ratelimits.json so the Web UI can display the most recent values across cloop invocations.
Index ¶
- Constants
- Variables
- func Clear()
- func ClearUsageCache()
- func ClearUsageCacheIn(dir string)
- func EnforceClaudeCodeLimits(cfg config.ClaudeCodeConfig, usage *ClaudeUsage) error
- func ForceCredentialRefresh() string
- func Probe(apiKey, model string) error
- func ProbeAll(apiKey string)
- func ReauthRequired() bool
- func ReauthRequiredIn(dir string) bool
- func Record(model string, h http.Header)
- func Snapshot() map[string]ModelLimits
- type AuthError
- type AuthProblem
- type CapExceededError
- type ClaudeUsage
- func FetchClaudeUsage(token string) (*ClaudeUsage, error)
- func FetchClaudeUsageIn(dir, token string) (*ClaudeUsage, error)
- func FetchOrCachedUsage(token string, ttl time.Duration) (*ClaudeUsage, error)
- func FetchOrCachedUsageIn(dir, token string, ttl time.Duration) (*ClaudeUsage, error)
- func GetCachedUsage() *ClaudeUsage
- func GetCachedUsageIn(dir string) *ClaudeUsage
- type ClaudeUsageResponse
- type ExtraUsage
- type LimitViolation
- type ModelLimits
- type RateLimitError
- type UsageDetail
- type UsageWindow
- type Window
Constants ¶
const MinUsageCacheTTL = time.Minute
MinUsageCacheTTL is the floor on how long a fetched ClaudeUsage snapshot is served without re-fetching. The OAuth usage API returns slowly-changing utilization percentages and is itself rate-limited, so callers that ask "may I make another claudecode call?" within this window receive the cached snapshot without a network round-trip. Callers may pass a longer TTL but anything shorter is rounded up to this value.
Variables ¶
var ( // ErrReauthRequired marks every failure that a human fixes by logging // into Claude Code again. ErrReauthRequired = errors.New("claude code re-authentication required") // ErrNoCredentials means no Claude Code credentials were found at all — // neither a usable credentials file nor CLAUDE_CODE_OAUTH_TOKEN. ErrNoCredentials = errors.New("no claude code credentials available") )
Sentinels for errors.Is, so callers never string-match. A transient failure resolves itself on the next refresh; these two do not, and the difference is exactly what the dashboard needs in order to say something useful.
var ErrUsageRateLimited = errors.New("claude usage API rate limited")
ErrUsageRateLimited marks a usage fetch that the server refused for rate reasons. Callers match it with errors.Is rather than string-matching the "Rate limited. Please try again later." body.
Functions ¶
func ClearUsageCache ¶
func ClearUsageCache()
ClearUsageCache discards the in-memory ClaudeUsage snapshot so the next FetchOrCachedUsage call goes back to the OAuth usage API. Call this on reauthentication (login completes, logout) — the cached snapshot was tied to the previous identity's account and would otherwise be served as stale data for up to MinUsageCacheTTL.
func ClearUsageCacheIn ¶ added in v0.0.2
func ClearUsageCacheIn(dir string)
ClearUsageCacheIn is ClearUsageCache scoped to one identity, so a user logging in or out invalidates only their own snapshot and leaves every other tenant's cached usage intact.
func EnforceClaudeCodeLimits ¶
func EnforceClaudeCodeLimits(cfg config.ClaudeCodeConfig, usage *ClaudeUsage) error
EnforceClaudeCodeLimits returns a non-nil *CapExceededError when any of the per-project caps configured in cfg has been reached. Caller is responsible for fetching the usage snapshot (so that this check can be done without a network call when fresh cached data is available).
func ForceCredentialRefresh ¶
func ForceCredentialRefresh() string
ForceCredentialRefresh returns a currently-valid Claude Code OAuth access token, rotating the stored credential when it is past its recorded expiry.
It is safe to call concurrently and from multiple processes: the underlying exchange holds the process-wide oauthRefreshMu plus a cross-process flock, and re-checks freshness after taking them. When a peer already rotated the token while this caller was blocked, the peer's token is returned rather than spending the (now consumed) refresh token a second time — so a whole parallel round of 401s coalesces into at most one exchange.
Returns "" when no refresh is possible: no credentials file, no refresh token, or a failed exchange. Callers should treat that as "retry with whatever the CLI resolves for itself" rather than as a hard failure, since the credential may legitimately come from elsewhere — CLAUDE_CODE_OAUTH_TOKEN, or a long-lived `claude setup-token` credential that carries no refresh token at all.
func Probe ¶
Probe makes a lightweight Anthropic API call (1 token max) to capture rate-limit headers for the given model. This is used when the claudecode CLI provider is active and no headers are available from direct API calls. apiKey can be an API key or OAuth token (sk-ant-oat01-*).
func ProbeAll ¶
func ProbeAll(apiKey string)
ProbeAll probes rate limits for common Anthropic models.
func ReauthRequired ¶
func ReauthRequired() bool
ReauthRequired reports whether the last attempt failed in a way a human fixes by logging in again.
func ReauthRequiredIn ¶ added in v0.0.2
ReauthRequiredIn is ReauthRequired scoped to one identity.
func Record ¶
Record extracts anthropic-ratelimit-* headers from h and stores them under model. Safe to call from concurrent provider goroutines. Headers absent from the response are not zeroed; the previous values for those dimensions are preserved.
func Snapshot ¶
func Snapshot() map[string]ModelLimits
Snapshot returns a deep-ish copy of the current per-model rate-limit map. Map keys are model names. Caller may freely read from the result without locking.
Types ¶
type AuthError ¶
type AuthError struct {
Problem AuthProblem
// Detail is the underlying cause, safe to show an operator. It never
// contains token material — only paths, statuses and server messages.
Detail string
}
AuthError is a classified authentication failure carrying an operator-facing recovery hint. It unwraps to ErrReauthRequired.
func AuthFailure ¶
func AuthFailure() *AuthError
AuthFailure reports the currently cached authentication failure for this process's own credential, or nil when the last usage fetch authenticated successfully. The dashboard uses this to decide whether to show the re-authentication banner without triggering a fetch of its own.
func AuthFailureIn ¶ added in v0.0.2
AuthFailureIn is AuthFailure scoped to one identity's configuration directory.
type AuthProblem ¶
type AuthProblem string
AuthProblem classifies why the OAuth credential is unusable. It is serialized to the dashboard, so the values are stable identifiers.
const ( // AuthNoCredentials: no credentials file, no access token in it, and no // CLAUDE_CODE_OAUTH_TOKEN in the environment. AuthNoCredentials AuthProblem = "no_credentials" // AuthRefreshFailed: a refresh token existed but the exchange failed. // Usually means the single-use refresh token was already consumed. AuthRefreshFailed AuthProblem = "refresh_failed" // AuthRejected: a token was sent and the server answered 401. This is // the only classification backed by the server's own opinion. AuthRejected AuthProblem = "rejected" // AuthScopeInsufficient: the token authenticates but was not granted the // scope the usage endpoint requires (HTTP 403 oauth_scope_insufficient). // Retrying and refreshing are both useless — the grant itself is too // narrow, so only a fresh login with the right scopes fixes it. AuthScopeInsufficient AuthProblem = "scope_insufficient" )
type CapExceededError ¶ added in v0.0.2
type CapExceededError struct {
Violations []LimitViolation
}
CapExceededError is the error EnforceClaudeCodeLimits returns when a cap has been reached. It carries the violations themselves so the caller can record when the run may resume instead of only being able to print why it stopped.
func (*CapExceededError) Error ¶ added in v0.0.2
func (e *CapExceededError) Error() string
func (*CapExceededError) Label ¶ added in v0.0.2
func (e *CapExceededError) Label() string
Label names the tripped windows in the short form an operator reads on a project card: "5-hour cap reached", or "5-hour and weekly caps reached".
func (*CapExceededError) ResumesAt ¶ added in v0.0.2
func (e *CapExceededError) ResumesAt() time.Time
ResumesAt is the instant every violated window has rolled over, or the zero time when that cannot be known.
It is the *latest* reset among the violations, not the earliest: resuming when the 5-hour window reopens while the weekly cap is still tripped would pause the run again on its first task, and a fleet that does that every five hours is no better off than one that stalled. A single violation whose reset the API did not report makes the whole answer unknown for the same reason — a guess here parks a project for hours or hot-loops it against the wall.
type ClaudeUsage ¶
type ClaudeUsage struct {
FiveHour *UsageDetail `json:"five_hour,omitempty"`
SevenDay *UsageDetail `json:"seven_day,omitempty"`
SevenDayOpus *UsageDetail `json:"seven_day_opus,omitempty"`
SevenDaySonnet *UsageDetail `json:"seven_day_sonnet,omitempty"`
ExtraUsage *ExtraUsage `json:"extra_usage,omitempty"`
FetchedAt time.Time `json:"fetched_at"`
}
ClaudeUsage is the processed usage data exposed to the UI.
func FetchClaudeUsage ¶
func FetchClaudeUsage(token string) (*ClaudeUsage, error)
FetchClaudeUsage calls the Anthropic OAuth usage API to get subscription limits (5-hour window, weekly window, per-model breakdowns). The token should be a Claude Code OAuth access token (sk-ant-oat01-*).
func FetchClaudeUsageIn ¶ added in v0.0.2
func FetchClaudeUsageIn(dir, token string) (*ClaudeUsage, error)
FetchClaudeUsageIn is FetchClaudeUsage scoped to one identity's Claude CLI configuration directory.
func FetchOrCachedUsage ¶
func FetchOrCachedUsage(token string, ttl time.Duration) (*ClaudeUsage, error)
FetchOrCachedUsage returns the cached usage snapshot when it is fresher than max(ttl, MinUsageCacheTTL), otherwise re-fetches from the OAuth usage API. Concurrent callers coalesce on usageFetchMu so the API is hit once per refresh window even under contention. On fetch error, a previously cached snapshot (if any) is returned alongside the error so callers can fall back to stale data instead of failing open.
func FetchOrCachedUsageIn ¶ added in v0.0.2
func FetchOrCachedUsageIn(dir, token string, ttl time.Duration) (*ClaudeUsage, error)
FetchOrCachedUsageIn is FetchOrCachedUsage scoped to one identity's Claude CLI configuration directory. A hub calls this with the requesting user's directory so the caps panel reports that user's own subscription.
func GetCachedUsage ¶
func GetCachedUsage() *ClaudeUsage
GetCachedUsage returns the last fetched usage, or nil if not available. No freshness check — callers that need fresh data should use FetchOrCachedUsage instead.
func GetCachedUsageIn ¶ added in v0.0.2
func GetCachedUsageIn(dir string) *ClaudeUsage
GetCachedUsageIn is GetCachedUsage scoped to one identity's configuration directory.
type ClaudeUsageResponse ¶
type ClaudeUsageResponse struct {
FiveHour *UsageWindow `json:"five_hour"`
SevenDay *UsageWindow `json:"seven_day"`
SevenDayOpus *UsageWindow `json:"seven_day_opus"`
SevenDaySonnet *UsageWindow `json:"seven_day_sonnet"`
ExtraUsage *ExtraUsage `json:"extra_usage"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
ClaudeUsageResponse is the raw API response from /api/oauth/usage.
type ExtraUsage ¶
type ExtraUsage struct {
IsEnabled bool `json:"is_enabled"`
MonthlyLimit *float64 `json:"monthly_limit"`
UsedCredits *float64 `json:"used_credits"`
Utilization *float64 `json:"utilization"`
}
ExtraUsage represents extra/overflow usage info.
type LimitViolation ¶
type LimitViolation struct {
Window string // "weekly", "five_hour", "weekly_opus", "weekly_sonnet"
Utilization float64 // current global utilization 0-100
Cap float64 // configured per-project cap 0-100
// ResetsAt is when this window rolls over, as reported by the OAuth
// usage API. Zero when the API did not say — the snapshot omits the
// window's reset, or it was served from a cache written before the
// field existed. A caller must treat zero as "unknown", never as "now".
ResetsAt time.Time
}
LimitViolation describes a single tripped per-project claudecode cap.
func CheckClaudeCodeLimits ¶
func CheckClaudeCodeLimits(cfg config.ClaudeCodeConfig, usage *ClaudeUsage) []LimitViolation
CheckClaudeCodeLimits compares the configured per-project caps against a usage snapshot and returns the list of windows whose configured cap has been reached. Returns nil when no caps are tripped (or no caps configured).
usage may be nil — in that case CheckClaudeCodeLimits returns nil (no enforcement when usage data is unavailable).
func (LimitViolation) Error ¶
func (v LimitViolation) Error() string
func (LimitViolation) Label ¶ added in v0.0.2
func (v LimitViolation) Label() string
Label is the short operator-facing phrase for this violation, the form that reads well after "Paused:" — "5-hour cap reached".
type ModelLimits ¶
type ModelLimits struct {
Model string `json:"model"`
UpdatedAt time.Time `json:"updated_at"`
Tier string `json:"tier,omitempty"` // anthropic-priority-tier or similar
Requests Window `json:"requests"` // RPM
Tokens Window `json:"tokens"` // overall TPM (legacy, may be 0)
InputTokens Window `json:"input_tokens"` // ITPM
OutputTokens Window `json:"output_tokens"` // OTPM
Weekly Window `json:"weekly,omitempty"` // weekly window if present
FiveHour Window `json:"five_hour,omitempty"` // 5-hour rolling window
MonthlySpendUSD float64 `json:"monthly_spend_usd,omitempty"` // x-spend-limit-monthly if present
// Raw is the full set of anthropic-ratelimit-* headers as returned by the
// API, for debugging / forward-compatibility.
Raw map[string]string `json:"raw,omitempty"`
}
ModelLimits is the full rate-limit snapshot for one model.
type RateLimitError ¶ added in v0.0.3
type RateLimitError struct {
Status int
// RetryAfter is the server's instruction, or 0 when it gave none. It is a
// floor on the backoff, never a ceiling: a server asking for ten minutes
// gets ten minutes even though our own escalation would have asked for
// less.
RetryAfter time.Duration
Detail string
}
RateLimitError is a usage fetch refused with 429 (or an overload status), carrying the server's own Retry-After when it sent one.
func (*RateLimitError) Error ¶ added in v0.0.3
func (e *RateLimitError) Error() string
func (*RateLimitError) Unwrap ¶ added in v0.0.3
func (e *RateLimitError) Unwrap() error
type UsageDetail ¶
type UsageDetail struct {
Utilization float64 `json:"utilization"` // 0-100
ResetsAt time.Time `json:"resets_at"`
}
UsageDetail is a single limit window with parsed fields.
type UsageWindow ¶
type UsageWindow struct {
Utilization *float64 `json:"utilization"` // percentage 0-100, nil if not applicable
ResetsAt string `json:"resets_at"` // ISO 8601 timestamp
}
UsageWindow represents a single usage limit window (5-hour or 7-day).