Skip to content

fix!: isolate token buckets per key and manage limiter lifecycle - #4

Merged
christiangda merged 4 commits into
mainfrom
fix/per-key-limiter-isolation
Jul 11, 2026
Merged

fix!: isolate token buckets per key and manage limiter lifecycle#4
christiangda merged 4 commits into
mainfrom
fix/per-key-limiter-isolation

Conversation

@christiangda

Copy link
Copy Markdown
Contributor

Summary

This PR fixes a critical correctness bug and modernizes the library.

The headline bug: every key shared a single underlying rate.Limiter, so all keys drained one token bucket — the core promise of per-key rate limiting did not actually hold. A new regression test (TestBucketLimiter_PerKeyIsolation) fails on the old code and passes now.

What changed

Correctness

  • Per-key isolation: GetOrAdd now builds an independent Limiter per key via a func() Limiter factory. One key exhausting its budget no longer affects others.
  • Atomic creation: key creation uses LoadOrStore, fixing a TOCTOU race that could create duplicate limiters for the same key.
  • Correct eviction semantics + lifecycle: replaced the per-key sleeping goroutine (which deleted after a fixed time from creation, leaked a goroutine per key, and reset active buckets) with a single background sweeper that evicts genuinely idle keys — the idle timer is refreshed on every access. Added Close() to stop it.

API / best practices (Go 1.25)

  • Storage[K, V] and BucketLimiter[K] are now generic for type safety.
  • Added NewRateLimiterFunc(limit, burst) helper and WithClock / WithSweepInterval options.
  • Storage gained LoadOrStore and Range.

Examples & headers

  • Middleware uses net.SplitHostPort (IPv6-safe client IP).
  • Middleware sets accurate Retry-After (from the reservation delay) and RateLimit-Limit/Remaining/Reset headers (IETF draft names + legacy X-RateLimit-*).
  • examples/key is a minimal demo of per-key isolation and refill over time.

Docs & tests

  • New docs/TOKEN_BUCKET.md: a thorough explanation of the token bucket algorithm, choosing limit/burst, Allow vs Wait vs Reserve, eviction, HTTP headers, algorithm comparison, and single-process vs distributed scope.
  • Rewrote README.md and added package-level doc.go.
  • Rewrote tests: isolation regression, deterministic eviction via an injected clock (de-flaked the old timing-bound Wait test), concurrency, storage, and benchmarks.

Verification

  • go test -race ./... — passing, 100% statement coverage.
  • go vet ./..., gofmt — clean.
  • Both examples run; middleware verified end-to-end (200 with RateLimit-* headers → 429 with Retry-After: 1).

⚠️ Breaking changes

  • NewBucketLimiter takes a func() Limiter factory instead of a Limiter instance.
  • Storage and BucketLimiter are generic; NewInMemoryStorage requires type parameters (NewInMemoryStorage[string, ratelimiter.Limiter]()).
  • BucketLimiter no longer implements Limiter — call GetOrAdd(key) to obtain one.
  • Remove no longer returns an error.

Scope note

This library limits within a single process. Global cross-instance limiting needs a distributed algorithm and remains out of scope; Storage is for custom in-process stores.

🤖 Generated with Claude Code

Previously every key shared a single underlying rate.Limiter, so all keys
drained one token bucket and per-key limiting did not actually work.
GetOrAdd now builds an independent Limiter per key via a factory.

Also:
- Make key creation atomic with LoadOrStore (fixes a TOCTOU race that
  could create duplicate limiters for the same key).
- Replace the per-key sleeping cleanup goroutine with a single background
  sweeper that evicts genuinely idle keys (idle timer refreshed on every
  access) and add Close() to stop it.
- Make Storage[K, V] and BucketLimiter[K] generic for type safety.
- Add NewRateLimiterFunc helper and WithClock/WithSweepInterval options.
- Rewrite tests: per-key isolation regression test, deterministic
  eviction via an injected clock, concurrency and storage tests, and
  benchmarks (100% statement coverage, race-clean).
- Examples: IPv6-safe client IP via net.SplitHostPort, accurate
  Retry-After and RateLimit-* response headers, and Close() usage.
- Add docs/TOKEN_BUCKET.md and rewrite README.

BREAKING CHANGE: NewBucketLimiter now takes a func() Limiter factory
instead of a Limiter instance; Storage and BucketLimiter are generic and
NewInMemoryStorage requires type parameters; BucketLimiter no longer
implements Limiter (call GetOrAdd(key) to obtain one); Remove no longer
returns an error.
@christiangda christiangda self-assigned this Jul 11, 2026
Add docs/MIGRATION.md with before/after mappings for every breaking
change (factory-based NewBucketLimiter, generic storage, Close(), the
manager no longer being a Limiter, Remove's dropped error, custom
Storage) plus the behavioral changes, and link it from the README.
…s/ratelimiter into fix/per-key-limiter-isolation
@christiangda
christiangda merged commit 5c406d4 into main Jul 11, 2026
1 check passed
@christiangda
christiangda deleted the fix/per-key-limiter-isolation branch July 11, 2026 11:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant