package ratelimit import ( "context" "net/http" "strconv" "sync" "time" "github.com/rs/zerolog/log" ) // Window holds per-window usage state. type Window struct { Utilization float64 // 0-100 from API ResetsAt time.Time // when window resets } // Snapshot is a read-only copy of a Window's state. type Snapshot struct { Utilization float64 ResetsAt time.Time } // Tracker polls /api/oauth/usage and tracks per-window token usage. type Tracker struct { tokenFn func() string // returns current OAuth access token mu sync.RWMutex fiveHour Window sevenDay Window sonnet Window extra ExtraUsage } // NewTracker creates a tracker. tokenFn should return the current access token. func NewTracker(tokenFn func() string) *Tracker { return &Tracker{tokenFn: tokenFn} } // Start begins the background poll loop. func (t *Tracker) Start(ctx context.Context) { go func() { // Poll immediately on start t.poll(ctx) for { select { case <-ctx.Done(): return case <-time.After(5 * time.Minute): t.poll(ctx) } } }() } // UpdateFromHeaders extracts rate limit data from /v1/messages response headers. // This provides real-time utilization updates without polling the usage API. func (t *Tracker) UpdateFromHeaders(h http.Header) { t.mu.Lock() defer t.mu.Unlock() if v := h.Get("Anthropic-Ratelimit-Unified-5h-Utilization"); v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil { t.fiveHour.Utilization = f * 100 // header is 0-1, we store 0-100 } } if v := h.Get("Anthropic-Ratelimit-Unified-5h-Reset"); v != "" { if ts, err := strconv.ParseInt(v, 10, 64); err == nil { t.fiveHour.ResetsAt = time.Unix(ts, 0).UTC().Truncate(time.Minute) } } if v := h.Get("Anthropic-Ratelimit-Unified-7d-Utilization"); v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil { t.sevenDay.Utilization = f * 100 } } if v := h.Get("Anthropic-Ratelimit-Unified-7d-Reset"); v != "" { if ts, err := strconv.ParseInt(v, 10, 64); err == nil { t.sevenDay.ResetsAt = time.Unix(ts, 0).UTC().Truncate(time.Minute) } } } // FiveHour returns a snapshot of the 5-hour window. func (t *Tracker) FiveHour() Snapshot { t.mu.RLock() defer t.mu.RUnlock() return Snapshot{ Utilization: t.fiveHour.Utilization, ResetsAt: t.fiveHour.ResetsAt, } } // SevenDay returns a snapshot of the 7-day window. func (t *Tracker) SevenDay() Snapshot { t.mu.RLock() defer t.mu.RUnlock() return Snapshot{ Utilization: t.sevenDay.Utilization, ResetsAt: t.sevenDay.ResetsAt, } } // Sonnet returns a snapshot of the 7-day sonnet window. func (t *Tracker) Sonnet() Snapshot { t.mu.RLock() defer t.mu.RUnlock() return Snapshot{ Utilization: t.sonnet.Utilization, ResetsAt: t.sonnet.ResetsAt, } } // Extra returns the current extra usage state. func (t *Tracker) Extra() ExtraUsage { t.mu.RLock() defer t.mu.RUnlock() return t.extra } func (t *Tracker) poll(ctx context.Context) { token := t.tokenFn() if token == "" { return } usage, err := fetchUsage(ctx, token) if err != nil { log.Warn().Err(err).Msg("usage poll failed") return } t.mu.Lock() defer t.mu.Unlock() if usage.FiveHour != nil { t.updateWindow(&t.fiveHour, usage.FiveHour) } if usage.SevenDay != nil { t.updateWindow(&t.sevenDay, usage.SevenDay) } if usage.SevenDaySonnet != nil { t.updateWindow(&t.sonnet, usage.SevenDaySonnet) } if usage.ExtraUsage != nil { t.extra = *usage.ExtraUsage } log.Debug(). Float64("5h_util", t.fiveHour.Utilization). Time("5h_resets", t.fiveHour.ResetsAt). Float64("7d_util", t.sevenDay.Utilization). Time("7d_resets", t.sevenDay.ResetsAt). Msg("usage poll") // Warn on high utilization if t.fiveHour.Utilization > 80 { log.Warn().Float64("utilization", t.fiveHour.Utilization).Time("resets_at", t.fiveHour.ResetsAt).Msg("5h window utilization high") } if t.sevenDay.Utilization > 80 { log.Warn().Float64("utilization", t.sevenDay.Utilization).Time("resets_at", t.sevenDay.ResetsAt).Msg("7d window utilization high") } } func (t *Tracker) updateWindow(w *Window, rl *RateLimit) { if rl.Utilization != nil { w.Utilization = *rl.Utilization } if rl.ResetsAt != nil { parsed, err := time.Parse(time.RFC3339Nano, *rl.ResetsAt) if err != nil { parsed, err = time.Parse(time.RFC3339, *rl.ResetsAt) } if err == nil { w.ResetsAt = parsed.UTC().Truncate(time.Minute) } } }