Files
scrabble-game/gateway/internal/ratelimit/tracker.go
T
Ilia Denisov 8878711cf3 R3: gateway edge hardening — body cap, h2c sizing, rate-limit observability
- GATEWAY_MAX_BODY_BYTES (1 MiB): connect WithReadMaxBytes + http.MaxBytesReader
  on the public mux; explicit http2.Server MaxConcurrentStreams/IdleTimeout and
  an http.Server ReadHeaderTimeout (R2 report follow-up).
- gateway_rate_limited_total{class} counter, Debug per rejection, a rejection
  tracker drained every 30 s into a Warn summary per key and a report POST to
  /api/v1/internal/ratelimit/report (feeds the admin view + auto-flag).
- The dead AdminPerMinute/AdminBurst policy now guards the /_gm mount (429),
  ahead of its Basic-Auth.
- resolve() logs the cause of infra session-resolve failures at Warn (the
  transient unauthenticated dips from the R2 run); unknown tokens stay silent.
2026-06-10 01:58:48 +02:00

53 lines
1.5 KiB
Go

package ratelimit
import "sync"
// Rejection aggregates the limiter rejections of one key within one report
// window. Class is the limiter class (user, public, email or admin); Key is the
// class-specific subject — an account id for the user class, a client IP for the
// others. The JSON shape is the gateway→backend rate-limit report wire contract.
type Rejection struct {
Class string `json:"class"`
Key string `json:"key"`
Rejected int `json:"rejected"`
}
// Tracker accumulates limiter rejections between drains. The gateway's periodic
// reporter drains it to emit the per-key log summary and the backend report; the
// per-rejection cost is one map increment under a mutex, safe on the hot path.
type Tracker struct {
mu sync.Mutex
m map[trackerKey]int
}
type trackerKey struct{ class, key string }
// NewTracker constructs an empty Tracker.
func NewTracker() *Tracker {
return &Tracker{m: make(map[trackerKey]int)}
}
// Add counts one rejection of key under class.
func (t *Tracker) Add(class, key string) {
t.mu.Lock()
defer t.mu.Unlock()
t.m[trackerKey{class: class, key: key}]++
}
// Drain returns the rejections accumulated since the previous drain, in
// unspecified order, and resets the tracker. It returns nil when nothing was
// rejected.
func (t *Tracker) Drain() []Rejection {
t.mu.Lock()
defer t.mu.Unlock()
if len(t.m) == 0 {
return nil
}
out := make([]Rejection, 0, len(t.m))
for k, n := range t.m {
out = append(out, Rejection{Class: k.class, Key: k.key, Rejected: n})
}
clear(t.m)
return out
}