limiter.go 1.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
  1. // Package ratelimit implements the per-source and per-company token
  2. // bucket from SPEC §22 layers 3 & 4.
  3. //
  4. // We use the INCR + EXPIRE pattern (works on stock Redis, no
  5. // redis-cell module needed). The window is 1 second; rate is
  6. // cap tokens / second. Tokens are integer; the bucket refills
  7. // atomically on the next request.
  8. //
  9. // This is an *approximation* of a token bucket — a leaky bucket
  10. // would be more accurate. Good enough for protecting the tier.
  11. package ratelimit
  12. import (
  13. "context"
  14. "fmt"
  15. "time"
  16. "github.com/redis/go-redis/v9"
  17. )
  18. const windowSeconds = 1
  19. type Limiter struct {
  20. rdb *redis.Client
  21. }
  22. func New(rdb *redis.Client) *Limiter { return &Limiter{rdb: rdb} }
  23. // Allow consumes one token from the bucket `key` and returns:
  24. //
  25. // (true, 0, nil) – allowed
  26. // (false, ttl, nil) – denied, ttl is the seconds until reset
  27. // (false, 0, err) – redis error
  28. //
  29. // The cap is the per-second limit. If cap <= 0 the limiter is a
  30. // no-op (always allow) — useful for sources we explicitly disable.
  31. func (l *Limiter) Allow(ctx context.Context, key string, cap int) (bool, time.Duration, error) {
  32. if cap <= 0 {
  33. return true, 0, nil
  34. }
  35. full := fmt.Sprintf("rl:%s:%d", key, time.Now().Unix())
  36. pipe := l.rdb.Pipeline()
  37. incr := pipe.Incr(ctx, full)
  38. pipe.Expire(ctx, full, windowSeconds*2*time.Second)
  39. if _, err := pipe.Exec(ctx); err != nil {
  40. return false, 0, fmt.Errorf("ratelimit: %w", err)
  41. }
  42. n := incr.Val()
  43. if n > int64(cap) {
  44. ttl, _ := l.rdb.TTL(ctx, full).Result()
  45. if ttl < 0 {
  46. ttl = time.Second
  47. }
  48. return false, ttl, nil
  49. }
  50. return true, 0, nil
  51. }