From 3ca1d2c4370e8e79aba3940d5c8fe115960b75a8 Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 15:18:21 -0300 Subject: Initial import --- internal/bot/ratelimit.go | 139 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 internal/bot/ratelimit.go (limited to 'internal/bot/ratelimit.go') diff --git a/internal/bot/ratelimit.go b/internal/bot/ratelimit.go new file mode 100644 index 0000000..b5333fd --- /dev/null +++ b/internal/bot/ratelimit.go @@ -0,0 +1,139 @@ +package bot + +import ( + "sync" + "time" +) + +// The public bot answers anyone, so one person with a script can spend the +// whole service's budget against the cep.is API. These constants bound what a +// single Telegram account may cost. +// +// Burst is what a human actually does: paste a handful of CEPs in a row, then +// think. Refill is the sustained rate afterwards — 20 lookups a minute, which +// no person reaches by hand and which a loop hits within a second. +// +// WarnInterval keeps the refusal from becoming its own flood: someone held at +// the limit is told once, then ignored until the window passes, so a script +// hammering the bot does not turn into the bot hammering Telegram. +const ( + Burst = 5 + Refill = 3 * time.Second + WarnInterval = 30 * time.Second +) + +// The bucket table is swept rather than kept forever: every distinct user gets +// an entry, and a public bot meets a lot of users. An entry is dropped once it +// has been idle long enough that it would have refilled to full anyway, at +// which point forgetting it and recreating it are the same thing. +const ( + bucketIdleTTL = 15 * time.Minute + sweepEvery = 5 * time.Minute +) + +// Decision is the answer of [Limiter.Allow]. +type Decision struct { + // Allowed reports whether the request may proceed. + Allowed bool + + // Warn reports whether the user should be told they are being limited. + // It is true at most once per [WarnInterval] per key. + Warn bool + + // RetryAfter is roughly how long until the next token, meant for the + // message shown to the user. + RetryAfter time.Duration +} + +type bucket struct { + tokens float64 + lastSeen time.Time + lastWarn time.Time +} + +// Limiter is a token bucket per key, where the key is the Telegram user id — or +// the chat id for channel posts, which carry no author. +// +// It is in-memory and per-process on purpose: the bot runs as a single +// container, and a shared store would add a dependency and a failure mode to +// protect against something a single process can already see. +type Limiter struct { + burst float64 + refill time.Duration + warnInterval time.Duration + now func() time.Time + + mu sync.Mutex + buckets map[int64]*bucket + lastSweep time.Time +} + +// NewLimiter builds a limiter with the package constants. +func NewLimiter() *Limiter { + return NewLimiterWith(Burst, Refill, WarnInterval, time.Now) +} + +// NewLimiterWith builds a limiter with explicit parameters and an injectable +// clock, which is how the test suite exercises refilling without waiting for it. +func NewLimiterWith(burst int, refill, warnInterval time.Duration, now func() time.Time) *Limiter { + if now == nil { + now = time.Now + } + return &Limiter{ + burst: float64(burst), + refill: refill, + warnInterval: warnInterval, + now: now, + buckets: make(map[int64]*bucket), + } +} + +// Allow takes one token for key and reports what the caller should do. +func (l *Limiter) Allow(key int64) Decision { + now := l.now() + + l.mu.Lock() + defer l.mu.Unlock() + + l.sweep(now) + + b, ok := l.buckets[key] + if !ok { + b = &bucket{tokens: l.burst} + l.buckets[key] = b + } else if l.refill > 0 { + b.tokens += float64(now.Sub(b.lastSeen)) / float64(l.refill) + if b.tokens > l.burst { + b.tokens = l.burst + } + } + b.lastSeen = now + + if b.tokens >= 1 { + b.tokens-- + return Decision{Allowed: true} + } + + decision := Decision{RetryAfter: time.Duration((1 - b.tokens) * float64(l.refill))} + if b.lastWarn.IsZero() || now.Sub(b.lastWarn) >= l.warnInterval { + b.lastWarn = now + decision.Warn = true + } + return decision +} + +func (l *Limiter) sweep(now time.Time) { + if l.lastSweep.IsZero() { + l.lastSweep = now + return + } + if now.Sub(l.lastSweep) < sweepEvery { + return + } + l.lastSweep = now + for key, b := range l.buckets { + if now.Sub(b.lastSeen) >= bucketIdleTTL { + delete(l.buckets, key) + } + } +} -- cgit v1.3