ratelimit

package
v0.0.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 27, 2026 License: MIT Imports: 19 Imported by: 0

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

View Source
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

View Source
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.

View Source
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 Clear

func Clear()

Clear wipes all stored snapshots (used by tests).

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

func Probe(apiKey, model string) error

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

func ReauthRequiredIn(dir string) bool

ReauthRequiredIn is ReauthRequired scoped to one identity.

func Record

func Record(model string, h http.Header)

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

func AuthFailureIn(dir string) *AuthError

AuthFailureIn is AuthFailure scoped to one identity's configuration directory.

func (*AuthError) Error

func (e *AuthError) Error() string

func (*AuthError) Hint

func (e *AuthError) Hint() string

Hint returns the concrete next step for an operator. The dashboard renders this verbatim next to a link to the Claude Code login panel.

func (*AuthError) Unwrap

func (e *AuthError) Unwrap() error

Unwrap maps the classification onto a sentinel. "Nothing is configured" and "what is configured no longer works" call for different words in the UI, so they unwrap differently; every other case is a re-authentication.

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

type Window

type Window struct {
	Limit     int64     `json:"limit"`
	Remaining int64     `json:"remaining"`
	Reset     time.Time `json:"reset,omitempty"`
}

Window is a single rate-limit dimension snapshot.

func (Window) PercentUsed

func (w Window) PercentUsed() int

PercentUsed returns 0..100.

func (Window) Used

func (w Window) Used() int64

Used returns the consumed amount within the window.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL