A small, pluggable rate limiter for Go.
A policy (algorithm + rate + burst) is enforced by a Limiter, but the state
behind that policy lives in a pluggable Store — the broker. Swap the
broker and the same limiter goes from single-process to fleet-wide without
changing how you call it. Two algorithms ship in the box — token bucket and
leaky bucket — and any backend (in-memory, Redis, and beyond) is just a
Store.
go get github.com/0xnikshi/bouncerlim, err := bouncer.NewMemory(bouncer.Policy{
Algorithm: bouncer.TokenBucket,
Rate: 100, // sustained events per second, per key
Burst: 10, // max events in an instantaneous burst, per key
})
if err != nil {
log.Fatal(err)
}
ok, _ := lim.Allow(ctx, "user:42")
if !ok {
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}NewMemory is a convenience for New(NewMemoryStore(), policy). Limits are
applied per key, so "user:42" and "user:99" get independent buckets.
// A Policy is broker-agnostic — algorithm, rate, burst. No storage concerns.
type Policy struct {
Algorithm Algorithm
Rate float64
Burst int
}
// A Store is where bucket state lives. Implement it to add a backend.
type Store interface {
Allow(ctx context.Context, key string, p Policy, n int) (bool, error)
}
// A Limiter enforces a Policy against a Store.
func New(store Store, p Policy) (*Limiter, error)
func (l *Limiter) Allow(ctx context.Context, key string) (bool, error)
func (l *Limiter) AllowN(ctx context.Context, key string, n int) (bool, error)The call site is identical regardless of broker — only the Store you pass to
New changes.
| Algorithm | Behavior |
|---|---|
bouncer.TokenBucket |
Tokens accrue at Rate up to Burst. Allows bursts, caps the average. |
bouncer.LeakyBucket |
A queue of depth Burst draining at Rate. Smooths toward steady output. |
bouncer.FixedWindow |
Up to Burst events per window of Burst/Rate s; resets at the boundary. Cheap; boundary burst up to 2×Burst. |
bouncer.SlidingWindow |
Exact trailing window: keeps a timestamp per event, counts those within the last Burst/Rate s. Precise; memory grows with traffic. |
bouncer.SlidingWindowCounter |
Approximates the trailing count by weighting the previous window. O(1) state; smooths the fixed-window boundary. |
Every algorithm reads a Policy the same way — Burst is the burst/window size
and Rate is the long-run events/sec, giving a window of Burst/Rate seconds —
so switching algorithms is a one-field change. E.g. Rate: 1.67, Burst: 100 ≈
"100 events per minute".
Rough guidance: token bucket is the sensible default (bursty but bounded); leaky bucket when you want smooth, constant output; fixed window when you want the cheapest possible counter and can tolerate the boundary burst; sliding window when you need an exact trailing-window guarantee; sliding window counter when you want near-sliding-window accuracy at O(1) cost.
The in-memory store keeps state in one process — run several copies of your app
and each gets its own bucket, so a "100/sec" limit silently becomes
"100/sec × instances". For a fleet, use the redisstore broker: the bucket
lives in Redis and is shared by every instance, and the decision runs atomically
inside Redis via a Lua script so concurrent callers can't over-admit.
import (
"github.com/0xnikshi/bouncer"
"github.com/0xnikshi/bouncer/redisstore"
"github.com/redis/go-redis/v9"
)
client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
store, err := redisstore.New(client, redisstore.WithKeyPrefix("ratelimit:login:"))
if err != nil {
log.Fatal(err)
}
// Same Policy, same Limiter API — only the store changed.
lim, err := bouncer.New(store, bouncer.Policy{
Algorithm: bouncer.TokenBucket,
Rate: 100,
Burst: 20,
})
if err != nil {
log.Fatal(err)
}
ok, err := lim.Allow(ctx, "user:42")
if err != nil {
log.Printf("rate limiter degraded: %v", err) // fail-open: ok is true here
}
if !ok {
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}Notes:
- Failure mode — on a Redis outage the store fails open by default
(allows the event, returns the error so you can alert). Pass
redisstore.WithFailClosed(true)to deny during an outage instead. - Clock skew is a non-issue — bucket time comes from the Redis server, not each app instance, and idle keys auto-expire via a TTL.
- Dependency isolation — the core
bouncerpackage has no third-party dependencies; go-redis is pulled in only if you importredisstore.
Any backend — Memcached, SQL, DynamoDB — is just a Store. Implement one method:
type Store interface {
Allow(ctx context.Context, key string, p Policy, n int) (bool, error)
}The one rule: the whole read-modify-write must be atomic per key. In-memory
uses a mutex; Redis runs the logic server-side in a Lua script; SQL would use a
transaction. A naive get-then-set store races under concurrency and over-admits.
Optionally implement Supports(Algorithm) bool so New rejects unsupported
algorithms up front.
go run ./cmd/demo # in-memory store, no setup needed
# distributed store — needs Redis:
docker run --rm -p 6379:6379 redis:7
go run ./cmd/redisdemogo test -race ./...MemoryStorekeeps one bucket per key for the life of the process; with very high-cardinality keys the map grows unbounded. For such workloads useredisstore(keys carry a TTL) — or open an issue and an eviction option can be added.