Files
Alexander 27b647e9b4 refactor(ratelimit): remove per-window token tracking from proxy
Window token counts are now computed in Grafana using the @ modifier
with dashboard variables derived from proxy_usage_resets_at. This
eliminates in-memory state, file persistence, and restart sensitivity.

Removes: TokensIn/Out, RecordTokens, setResetTime, persist.go,
window_tokens observable gauges. -171 lines.
2026-04-14 14:25:31 +02:00

167 lines
4.1 KiB
Go

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 utilization.
type Tracker struct {
tokenFn func() string
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() {
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.
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
}
}
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")
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)
}
}
}