| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657 |
- // Package ratelimit implements the per-source and per-company token
- // bucket from SPEC §22 layers 3 & 4.
- //
- // We use the INCR + EXPIRE pattern (works on stock Redis, no
- // redis-cell module needed). The window is 1 second; rate is
- // cap tokens / second. Tokens are integer; the bucket refills
- // atomically on the next request.
- //
- // This is an *approximation* of a token bucket — a leaky bucket
- // would be more accurate. Good enough for protecting the tier.
- package ratelimit
- import (
- "context"
- "fmt"
- "time"
- "github.com/redis/go-redis/v9"
- )
- const windowSeconds = 1
- type Limiter struct {
- rdb *redis.Client
- }
- func New(rdb *redis.Client) *Limiter { return &Limiter{rdb: rdb} }
- // Allow consumes one token from the bucket `key` and returns:
- //
- // (true, 0, nil) – allowed
- // (false, ttl, nil) – denied, ttl is the seconds until reset
- // (false, 0, err) – redis error
- //
- // The cap is the per-second limit. If cap <= 0 the limiter is a
- // no-op (always allow) — useful for sources we explicitly disable.
- func (l *Limiter) Allow(ctx context.Context, key string, cap int) (bool, time.Duration, error) {
- if cap <= 0 {
- return true, 0, nil
- }
- full := fmt.Sprintf("rl:%s:%d", key, time.Now().Unix())
- pipe := l.rdb.Pipeline()
- incr := pipe.Incr(ctx, full)
- pipe.Expire(ctx, full, windowSeconds*2*time.Second)
- if _, err := pipe.Exec(ctx); err != nil {
- return false, 0, fmt.Errorf("ratelimit: %w", err)
- }
- n := incr.Val()
- if n > int64(cap) {
- ttl, _ := l.rdb.TTL(ctx, full).Result()
- if ttl < 0 {
- ttl = time.Second
- }
- return false, ttl, nil
- }
- return true, 0, nil
- }
|