This release contains breaking API changes alongside a critical correctness fix. This guide maps every change to a concrete before/after so you can upgrade mechanically.
Why the churn? The previous version shared a single token bucket across every key, so per-key limiting never actually worked. Fixing that required constructing an independent limiter per key, which is the root of most of the API changes below.
| Area | Before | After |
|---|---|---|
| Store construction | NewInMemoryStorage() |
NewInMemoryStorage[string, ratelimiter.Limiter]() |
| Manager construction | NewBucketLimiter(baseLimiter, d, storage) |
NewBucketLimiter(newLimiterFunc, d, storage) |
| Building the limiter | a single *rate.Limiter instance |
a func() Limiter factory (NewRateLimiterFunc(limit, burst)) |
| Lifecycle | none | defer manager.Close() |
| Getting a limiter | manager.GetOrAdd(key) |
manager.GetOrAdd(key) (unchanged) |
| Calling on the manager | manager.Allow() / .Wait() / .Burst() |
removed — use manager.GetOrAdd(key).Allow() etc. |
| Manual removal | err := manager.Remove(key) |
manager.Remove(key) (no return value) |
Storage interface |
non-generic, Store/Load/Delete |
generic Storage[K, V], adds LoadOrStore and Range |
| Default limiter type | NewRateLimiterFunc builds a bare *rate.Limiter |
builds a RateLimiter (wraps *rate.Limiter, adds Reserver) |
// Before
storage := ratelimiter.NewInMemoryStorage()
// After
storage := ratelimiter.NewInMemoryStorage[string, ratelimiter.Limiter]()K is your key type (commonly string); V is ratelimiter.Limiter.
A single *rate.Limiter cannot be shared across keys (that was the bug). Pass a
factory that builds a fresh limiter per key instead. Use the NewRateLimiterFunc
helper for the common case.
// Before
baseLimiter := rate.NewLimiter(rate.Limit(5), 10)
manager := ratelimiter.NewBucketLimiter(baseLimiter, time.Minute, storage)
// After
newLimiter := ratelimiter.NewRateLimiterFunc(rate.Limit(5), 10) // 5 rps, burst 10
manager := ratelimiter.NewBucketLimiter(newLimiter, time.Minute, storage)If you need custom construction per key, pass your own factory:
newLimiter := func() ratelimiter.Limiter {
return rate.NewLimiter(rate.Limit(5), 10)
}K is inferred from storage, so no explicit type arguments are needed on
NewBucketLimiter.
The manager now runs a single background sweeper for idle eviction. Stop it when you are done to avoid leaking a goroutine.
manager := ratelimiter.NewBucketLimiter(newLimiter, time.Minute, storage)
defer manager.Close()(If you set deleteAfter <= 0, eviction is disabled and no goroutine is started;
Close() is then a safe no-op.)
BucketLimiter used to expose Allow(), Wait() and Burst() directly — but
those operated on the shared bucket, which was the bug. They are removed. Always
go through a key:
// Before (operated on the shared bucket — incorrect)
if manager.Allow() { ... }
// After (per-key)
if manager.GetOrAdd(key).Allow() { ... }
err := manager.GetOrAdd(key).Wait(ctx)
burst := manager.GetOrAdd(key).Burst()// Before
if err := manager.Remove(key); err != nil { ... }
// After
manager.Remove(key)If you implemented Storage yourself, it is now generic and has two additional
methods:
type Storage[K comparable, V any] interface {
Store(key K, value V)
Load(key K) (value V, ok bool)
LoadOrStore(key K, value V) (actual V, loaded bool) // new — must be atomic
Delete(key K)
Range(f func(key K, value V) bool) // new
}LoadOrStoremust be atomic so concurrent callers racing on the same key all receive the same stored value; the manager relies on this to avoid creating duplicate limiters.Rangeis used by the manager only indirectly; implement it to iterate the current entries (returnfalsefromfto stop early).
The factory now hands out a ratelimiter.RateLimiter — a thin wrapper that
embeds *rate.Limiter and additionally implements the new
Reserver interface. This is what lets middleware compute
an accurate Retry-After for any backend without importing golang.org/x/time/rate.
Almost all code is unaffected: Allow, Wait, and Burst are promoted from the
embedded *rate.Limiter, so manager.GetOrAdd(key).Allow() is unchanged. The
only thing that breaks is a type assertion to the concrete *rate.Limiter:
// Before — downcast to reach Reserve/ReserveN:
if rl, ok := manager.GetOrAdd(key).(*rate.Limiter); ok {
res := rl.ReserveN(time.Now(), 1)
// ...
}
// After — feature-detect the backend-agnostic Reserver instead:
if r, ok := manager.GetOrAdd(key).(ratelimiter.Reserver); ok {
res := r.Reserve()
_ = res.Delay()
}
// Or, if you specifically need *rate.Limiter's richer API, assert the wrapper
// and reach the embedded field:
if rl, ok := manager.GetOrAdd(key).(ratelimiter.RateLimiter); ok {
res := rl.Limiter.ReserveN(time.Now(), 1)
// ...
}- Per-key isolation now works. Distinct keys have independent buckets; exhausting one key no longer throttles others. If you had worked around the old shared-bucket behavior, you can remove those workarounds.
- Eviction is now idle-based. A key is evicted after
deleteAfterof no use, and everyGetOrAddrefreshes its timer — previously a key was deleted a fixed duration after creation regardless of activity. Ensure yourdeleteAfterreflects "idle time", not "total lifetime".
// Before
storage := ratelimiter.NewInMemoryStorage()
baseLimiter := rate.NewLimiter(rate.Limit(5), 10)
manager := ratelimiter.NewBucketLimiter(baseLimiter, time.Minute, storage)
if manager.GetOrAdd("user-123").Allow() {
// ...
}
_ = manager.Remove("user-123")// After
storage := ratelimiter.NewInMemoryStorage[string, ratelimiter.Limiter]()
newLimiter := ratelimiter.NewRateLimiterFunc(rate.Limit(5), 10)
manager := ratelimiter.NewBucketLimiter(newLimiter, time.Minute, storage)
defer manager.Close()
if manager.GetOrAdd("user-123").Allow() {
// ...
}
manager.Remove("user-123")