ratelimit

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 24, 2026 License: MIT Imports: 12 Imported by: 0

README

ratelimit

CI Go Reference

Distributed rate limiting for Go, backed by Redis. Three algorithms behind one interface, plus net/http middleware to put them in front of an API.

Try the live playground → Send requests, spam 50 at once, and watch each algorithm decide in real time. It runs browser ports of the library's Lua scripts, checked against the Go library decision for decision.

  • Token bucket: bursts up to a capacity, then a steady refill rate.
  • Sliding window log: exact "at most N requests in any window".
  • Sliding window counter: approximate sliding window in constant memory.

Every decision is a single atomic Lua script (one round trip, no race conditions between app instances) and uses the Redis server clock, so instances with skewed clocks still agree.

Install

go get github.com/RanggaEghaPermana/ratelimit

Requires Go 1.22+ and Redis 5+ (or a compatible server such as Valkey). Works with *redis.Client, *redis.ClusterClient and any redis.UniversalClient from go-redis v9.

Quick start

rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

// Bursts of up to 20 requests, then a steady 10 per second.
limiter, err := ratelimit.NewTokenBucket(rdb, ratelimit.TokenBucketConfig{
	Capacity: 20,
	Rate:     10,
	Period:   time.Second,
})
if err != nil {
	log.Fatal(err)
}

res, err := limiter.Allow(ctx, "user:42")
if err != nil {
	// Redis is unavailable: decide whether to fail open or closed.
}
if !res.Allowed {
	// Reject, and tell the client to retry after res.RetryAfter.
}

Every limiter implements the same interface:

type Limiter interface {
	Allow(ctx context.Context, key string) (Result, error)
	AllowN(ctx context.Context, key string, n int) (Result, error) // charge n at once
	Reset(ctx context.Context, key string) error                   // e.g. after a successful login
}

type Result struct {
	Allowed    bool
	Limit      int           // capacity, or limit per window
	Remaining  int           // requests left right now
	RetryAfter time.Duration // wait before retrying (0 when allowed)
	ResetAfter time.Duration // until the full limit is back
}

Choosing an algorithm

Token bucket Sliding window log Sliding window counter
Constructor NewTokenBucket NewSlidingWindowLog NewSlidingWindowCounter
Limits burst + average rate N per window, exactly N per window, approximately
Bursts up to Capacity up to Limit up to Limit
Redis memory per key constant (small hash) grows with Limit (ZSET) constant (small hash)
Good for public APIs, smoothing login, OTP, payments: low limits that must be exact high limits such as 10k/hour

Not sure? Start with the token bucket. The algorithms are explained in detail, with worked timelines, in docs/algorithms.md.

// At most 5 login attempts in any 15 minutes, counted exactly.
logins, _ := ratelimit.NewSlidingWindowLog(rdb, ratelimit.SlidingWindowConfig{
	Limit:  5,
	Window: 15 * time.Minute,
}, ratelimit.WithPrefix("login"))

// 10,000 requests per hour per API key, in constant memory.
hourly, _ := ratelimit.NewSlidingWindowCounter(rdb, ratelimit.SlidingWindowConfig{
	Limit:  10_000,
	Window: time.Hour,
}, ratelimit.WithPrefix("api-hourly"))

Give each limiter its own prefix. Limiters with the same prefix and algorithm share counters, which is what you want across app instances but not between a "login" limit and an "api" limit.

HTTP middleware

mux.Handle("/api/", ratelimit.Middleware(limiter)(apiHandler))

Responses carry these headers:

Header Meaning
X-RateLimit-Limit configured limit
X-RateLimit-Remaining requests left right now
X-RateLimit-Reset seconds until the limit is fully restored
Retry-After seconds to wait (only on 429)

Options:

ratelimit.Middleware(limiter,
	ratelimit.WithKeyFunc(ratelimit.KeyByHeader("X-API-Key")), // default: KeyByIP
	ratelimit.WithFailOpen(),                                  // default: 503 when Redis fails
	ratelimit.WithLimitedHandler(myJSON429Handler),            // default: plain-text 429
	ratelimit.WithErrorHook(func(r *http.Request, err error) { // log or count errors
		log.Printf("rate limit: %v", err)
	}),
)

Keys. KeyByIP uses r.RemoteAddr and groups IPv6 clients by their /64 prefix, since one client usually controls a whole /64. KeyByHeader rejects requests without the header with 400, so clients cannot bypass the limit by leaving it out. Write your own KeyFunc for anything else, such as a user ID from your auth middleware.

Behind a proxy or load balancer, RemoteAddr is the proxy's address, so every client would share one limit. Rewrite RemoteAddr from the header your proxy sets before this middleware runs, and only trust that header when it comes from your proxy: clients can send any X-Forwarded-For they like.

Fail open or closed? When Redis is unreachable the middleware answers 503 by default. WithFailOpen lets traffic through instead, keeping your API up during a Redis outage without limiting. Keep the default for limits that are a security control, such as login.

Using gin, echo or another framework? Middleware is standard func(http.Handler) http.Handler, so it works with chi, gorilla/mux and http.ServeMux directly and with most frameworks through their net/http adapter. Or call limiter.Allow in your own middleware.

Example: rate limited API gateway

examples/gateway is a small reverse proxy with an exact 5-per-minute limit on /login (fails closed) and a token bucket per API key on everything else (fails open):

docker compose up -d
go run ./examples/gateway                     # proxies to a built-in echo backend
for i in $(seq 1 7); do curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/login; done
# 200 200 200 200 200 429 429

Production tips

  • Fail fast. The limiter runs on every request, so configure the Redis client with short timeouts and few retries. With go-redis defaults a dead Redis stalls each request for about two seconds; with the settings below it takes about 0.2s:

    redis.NewClient(&redis.Options{
    	Addr:          addr,
    	DialTimeout:   200 * time.Millisecond,
    	ReadTimeout:   100 * time.Millisecond,
    	WriteTimeout:  100 * time.Millisecond,
    	MaxRetries:    1,
    	DialerRetries: 1,
    })
    
  • Redis Cluster works out of the box: every call touches exactly one key.

  • Keys expire on their own. Each key's TTL is set to when it would be back to its full limit, so idle clients cost no memory.

  • Changing a limit takes effect immediately for existing keys; no migration is needed.

  • Redis key layout is <prefix>:<tb|swl|swc>:<key>, handy for debugging with redis-cli.

Development

make test              # unit tests with the race detector (in-memory Redis, no setup)
make redis-up          # start Redis 7 in Docker
make test-integration  # run the same contract tests against real Redis
make bench-redis       # benchmarks against real Redis
make memory            # Redis memory used per key, per algorithm

The playground in playground/ is a Vite + React app with separate desktop and mobile front ends (npm install && npm run dev there).

Unit tests run against miniredis with a controllable clock, so time-based behaviour (refill, window sliding, exact RetryAfter, key expiry) is tested deterministically without sleeping. The same contract suite, including a check that 200 concurrent callers never exceed the limit, also runs against real Redis 6, Redis 7 and Valkey 8 in CI.

Benchmarks

bench_test.go measures, for each algorithm:

  • BenchmarkAllow/*/hot_key: one client hammering a single key (mostly denied).
  • BenchmarkAllow/*/10k_keys: traffic spread over 10,000 clients (mostly allowed).
  • BenchmarkAllowParallel: concurrent callers over 10,000 keys.
  • BenchmarkRoundTrip and BenchmarkRoundTripParallel: a bare PING, the floor for any Redis-backed limiter.

Run them against a real Redis with make bench-redis. Without REDIS_ADDR they fall back to miniredis, whose numbers mostly measure miniredis itself (it builds a fresh Lua VM per script call) and say nothing about production throughput.

Results

Laptop with an Intel i5-12450H (8 cores, 12 threads), Linux, Redis 7.0.15 on localhost, Go 1.22, median of 5 runs. Absolute numbers on a busy laptop varied by up to 2x between runs, so read them as orders of magnitude and compare the algorithms with each other rather than with your hardware.

Latency and throughput

Serial latency, hot key / 10k keys Parallel throughput (12 goroutines)
PING (floor) 12.5 µs 113k/s
Token bucket 28.8 / 34.3 µs 50k/s
Sliding window log 33.7 / 34.5 µs 45k/s
Sliding window counter 31.8 / 35.7 µs 51k/s

Each call allocates about 610–670 B in 18–21 allocations on the client, mostly inside go-redis.

Redis CPU per decision (script execution time from INFO commandstats, excluding network I/O)

Hot key (mostly denied) 10k keys (mostly allowed)
Token bucket 4.8 µs 8.2 µs
Sliding window log 9.6 µs 11.8 µs
Sliding window counter 5.5 µs 11.1 µs

Denied calls record nothing, and the token bucket and counter skip writing entirely (the log only prunes entries that already expired), so a client hammering the limit costs Redis less than normal traffic does. That matters most during an attack. Skipping the write cut the hot-key cost of the token bucket from 7.4 µs and of the counter from 8.3 µs.

Redis memory per key (MEMORY USAGE with a full limit's worth of requests)

Limit Token bucket Sliding window log Sliding window counter
10 136 B 408 B 136 B
100 136 B 3,160 B 136 B
1,000 136 B 119,424 B 136 B

What limits throughput. During the parallel benchmark redis-server sat at 100% of one core: Redis runs scripts on a single thread, so one instance is the ceiling, here around 50k decisions per second. Every call touches exactly one key, so Redis Cluster spreads keys across shards and throughput grows with the number of shards. Across a real network, add your round-trip time to the latencies above. Within a data centre that is often 100 µs or more, which dwarfs the script itself.

License

MIT

Documentation

Overview

Package ratelimit provides distributed rate limiters backed by Redis, plus net/http middleware to put them in front of an API.

Three algorithms share one Limiter interface:

  • TokenBucket: allows bursts up to a capacity and a steady refill rate.
  • SlidingWindowLog: exact "at most N requests in any window", with memory proportional to N per key.
  • SlidingWindowCounter: approximate sliding window in constant memory.

Each decision runs as a single Lua script, so it is atomic and costs one round trip, and time comes from the Redis server clock, so application instances with skewed clocks still agree. Any number of processes sharing one Redis enforce one combined limit per key.

Basic use:

rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
limiter, err := ratelimit.NewTokenBucket(rdb, ratelimit.TokenBucketConfig{
	Capacity: 20,  // burst
	Rate:     10,  // tokens per Period
	Period:   time.Second,
})
if err != nil {
	log.Fatal(err)
}
res, err := limiter.Allow(ctx, "user:42")
if err != nil {
	// Redis is unavailable: decide whether to fail open or closed.
}
if !res.Allowed {
	// Reject, and tell the client to come back after res.RetryAfter.
}

Or as HTTP middleware:

mux.Handle("/api/", ratelimit.Middleware(limiter)(apiHandler))

Requires Redis 5 or newer (or a compatible server such as Valkey). Redis Cluster is supported: each call touches a single key.

Index

Examples

Constants

View Source
const DefaultPrefix = "ratelimit"

DefaultPrefix is the Redis key prefix used unless WithPrefix is given.

Variables

View Source
var (
	// ErrInvalidConfig is returned by the constructors for an unusable
	// configuration. The returned error wraps it with details.
	ErrInvalidConfig = errors.New("ratelimit: invalid config")

	// ErrInvalidN is returned by AllowN when n is below 1 or above the
	// configured limit, since such a request could never be allowed.
	ErrInvalidN = errors.New("ratelimit: n must be between 1 and the limit")
)
View Source
var ErrNoKey = errors.New("ratelimit: no rate limit key in request")

ErrNoKey is returned by a KeyFunc when a request has no rate limit key.

Functions

func KeyByIP

func KeyByIP(r *http.Request) (string, error)

KeyByIP keys requests by the client IP taken from r.RemoteAddr.

IPv6 addresses are grouped by their /64 prefix, because a single client is usually handed a whole /64 and could otherwise rotate addresses to dodge the limit.

RemoteAddr is the address of the direct peer. Behind a reverse proxy or load balancer that is the proxy itself, so first rewrite RemoteAddr from a header the proxy sets and clients cannot spoof, or use a custom KeyFunc. Never trust X-Forwarded-For from arbitrary clients.

func Middleware

func Middleware(l Limiter, opts ...MiddlewareOption) func(http.Handler) http.Handler

Middleware returns net/http middleware that applies l to every request.

Every response carries these headers:

X-RateLimit-Limit      the configured limit
X-RateLimit-Remaining  requests left right now
X-RateLimit-Reset      seconds until the limit is fully restored

Limited requests also get Retry-After (seconds) and, by default, 429 Too Many Requests. Requests whose key cannot be extracted get 400 Bad Request. Limiter failures get 503 unless WithFailOpen is set.

Example
package main

import (
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/redis/go-redis/v9"

	"github.com/RanggaEghaPermana/ratelimit"
)

func main() {
	rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
	limiter, err := ratelimit.NewTokenBucket(rdb, ratelimit.TokenBucketConfig{Capacity: 20, Rate: 10})
	if err != nil {
		log.Fatal(err)
	}

	api := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		fmt.Fprintln(w, "hello")
	})

	mux := http.NewServeMux()
	mux.Handle("/api/", ratelimit.Middleware(limiter,
		ratelimit.WithKeyFunc(ratelimit.KeyByHeader("X-API-Key")),
		ratelimit.WithFailOpen(),
		ratelimit.WithErrorHook(func(r *http.Request, err error) {
			log.Printf("rate limit %s %s: %v", r.Method, r.URL.Path, err)
		}),
	)(api))

	srv := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
	log.Fatal(srv.ListenAndServe())
}

Types

type Client

type Client interface {
	redis.Scripter
	Del(ctx context.Context, keys ...string) *redis.IntCmd
}

Client is the subset of the go-redis API the limiters need. *redis.Client, *redis.ClusterClient, *redis.Ring and redis.UniversalClient all satisfy it.

type KeyFunc

type KeyFunc func(r *http.Request) (string, error)

KeyFunc extracts the rate limit key from a request. Return ErrNoKey (or an error wrapping it) when the request carries no usable key.

func KeyByHeader

func KeyByHeader(name string) KeyFunc

KeyByHeader keys requests by the value of the named header, such as an API key. Requests without the header get ErrNoKey and are rejected, so a client cannot escape the limit by omitting it.

type Limiter

type Limiter interface {
	// Allow is shorthand for AllowN(ctx, key, 1).
	Allow(ctx context.Context, key string) (Result, error)

	// AllowN reports whether n requests for key may proceed now, and records
	// them if so. A denied call records nothing. n must be between 1 and the
	// configured limit, otherwise ErrInvalidN is returned.
	AllowN(ctx context.Context, key string, n int) (Result, error)

	// Reset forgets all state for key, restoring its full limit.
	Reset(ctx context.Context, key string) error
}

Limiter decides whether a request identified by a key may proceed.

All implementations in this package are safe for concurrent use, and the decision is atomic in Redis: any number of processes sharing the same Redis and prefix enforce one combined limit per key.

type MiddlewareOption

type MiddlewareOption func(*middlewareConfig)

MiddlewareOption configures Middleware.

func WithErrorHook

func WithErrorHook(fn func(r *http.Request, err error)) MiddlewareOption

WithErrorHook registers a function called whenever a key cannot be extracted or the limiter fails, typically to log or count the error. It is called before the response is written and must not write one itself.

func WithFailOpen

func WithFailOpen() MiddlewareOption

WithFailOpen lets requests through when the limiter itself fails, for example because Redis is unreachable. By default such requests get 503 Service Unavailable.

Failing open keeps your API up when Redis is down, at the cost of not limiting during the outage. Keep the default for endpoints where the limit is a security control, such as login.

func WithKeyFunc

func WithKeyFunc(fn KeyFunc) MiddlewareOption

WithKeyFunc sets how requests are keyed. The default is KeyByIP.

func WithLimitedHandler

func WithLimitedHandler(h http.Handler) MiddlewareOption

WithLimitedHandler sets the handler that responds to limited requests. The rate limit headers, including Retry-After, are already set when it runs, and it is responsible for writing the status code. The default replies 429 Too Many Requests with a plain text body.

type Option

type Option func(*options)

Option configures a limiter.

func WithPrefix

func WithPrefix(prefix string) Option

WithPrefix sets the Redis key prefix. Give each limiter its own prefix when several limiters share one Redis, for example "login" and "api"; limiters with the same prefix and algorithm share their counters.

type Result

type Result struct {
	// Allowed reports whether the request may proceed.
	Allowed bool

	// Limit is the configured maximum: the bucket capacity for a token
	// bucket, or the per-window limit for a sliding window.
	Limit int

	// Remaining is how many more requests would be allowed right now.
	Remaining int

	// RetryAfter is how long to wait before the same request would be
	// allowed. It is zero when Allowed is true.
	RetryAfter time.Duration

	// ResetAfter is how long until the key is back to its full limit if no
	// further requests arrive.
	ResetAfter time.Duration
}

Result describes the outcome of a single AllowN call.

type SlidingWindowConfig

type SlidingWindowConfig struct {
	// Limit is the maximum number of requests within any Window.
	Limit int

	// Window is the length of the rolling window. Must be at least 1ms.
	Window time.Duration
}

SlidingWindowConfig configures both sliding window limiters.

type SlidingWindowCounter

type SlidingWindowCounter struct {
	// contains filtered or unexported fields
}

SlidingWindowCounter is an approximate sliding window Limiter using constant memory per key.

It counts requests in the current and previous fixed windows and weights the previous count by how much of it still overlaps the sliding window. This assumes the previous window's traffic was evenly spread, so the effective limit can be off by a small fraction when traffic is bursty.

func NewSlidingWindowCounter

func NewSlidingWindowCounter(client Client, cfg SlidingWindowConfig, opts ...Option) (*SlidingWindowCounter, error)

NewSlidingWindowCounter returns an approximate sliding window limiter backed by client.

Example
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/redis/go-redis/v9"

	"github.com/RanggaEghaPermana/ratelimit"
)

func main() {
	rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

	// 10,000 requests per hour per API key, in constant memory per key.
	limiter, err := ratelimit.NewSlidingWindowCounter(rdb, ratelimit.SlidingWindowConfig{
		Limit:  10_000,
		Window: time.Hour,
	}, ratelimit.WithPrefix("api-hourly"))
	if err != nil {
		log.Fatal(err)
	}

	// A batch endpoint can charge several requests at once.
	res, err := limiter.AllowN(context.Background(), "key:abc123", 50)
	if err == nil && res.Allowed {
		fmt.Println("batch accepted")
	}
}

func (*SlidingWindowCounter) Allow

func (sw *SlidingWindowCounter) Allow(ctx context.Context, key string) (Result, error)

Allow implements Limiter.

func (*SlidingWindowCounter) AllowN

func (sw *SlidingWindowCounter) AllowN(ctx context.Context, key string, n int) (Result, error)

AllowN implements Limiter.

func (*SlidingWindowCounter) Reset

func (b *SlidingWindowCounter) Reset(ctx context.Context, key string) error

Reset implements Limiter.

type SlidingWindowLog

type SlidingWindowLog struct {
	// contains filtered or unexported fields
}

SlidingWindowLog is an exact sliding window Limiter: it allows a request only if fewer than Limit requests were accepted in the preceding Window.

It stores one sorted-set entry per accepted request, so memory per key grows with Limit. Prefer SlidingWindowCounter for large limits.

func NewSlidingWindowLog

func NewSlidingWindowLog(client Client, cfg SlidingWindowConfig, opts ...Option) (*SlidingWindowLog, error)

NewSlidingWindowLog returns an exact sliding window limiter backed by client.

Example
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/redis/go-redis/v9"

	"github.com/RanggaEghaPermana/ratelimit"
)

func main() {
	rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

	// At most 5 login attempts in any 15 minutes, counted exactly.
	logins, err := ratelimit.NewSlidingWindowLog(rdb, ratelimit.SlidingWindowConfig{
		Limit:  5,
		Window: 15 * time.Minute,
	}, ratelimit.WithPrefix("login"))
	if err != nil {
		log.Fatal(err)
	}

	ctx := context.Background()
	key := "user:alice"
	res, err := logins.Allow(ctx, key)
	if err != nil || !res.Allowed {
		fmt.Println("too many login attempts")
		return
	}

	// On a successful login, forget the failed attempts.
	_ = logins.Reset(ctx, key)
}

func (*SlidingWindowLog) Allow

func (sw *SlidingWindowLog) Allow(ctx context.Context, key string) (Result, error)

Allow implements Limiter.

func (*SlidingWindowLog) AllowN

func (sw *SlidingWindowLog) AllowN(ctx context.Context, key string, n int) (Result, error)

AllowN implements Limiter.

func (*SlidingWindowLog) Reset

func (b *SlidingWindowLog) Reset(ctx context.Context, key string) error

Reset implements Limiter.

type TokenBucket

type TokenBucket struct {
	// contains filtered or unexported fields
}

TokenBucket is a Limiter that allows bursts of up to Capacity requests and a sustained average of Rate requests per Period.

Each key starts with a full bucket. Every request takes a token, and tokens flow back continuously (fractions included) at Rate per Period, never exceeding Capacity.

func NewTokenBucket

func NewTokenBucket(client Client, cfg TokenBucketConfig, opts ...Option) (*TokenBucket, error)

NewTokenBucket returns a token bucket limiter backed by client.

Example
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/redis/go-redis/v9"

	"github.com/RanggaEghaPermana/ratelimit"
)

func main() {
	rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

	// Bursts of up to 20 requests, then a steady 10 per second.
	limiter, err := ratelimit.NewTokenBucket(rdb, ratelimit.TokenBucketConfig{
		Capacity: 20,
		Rate:     10,
		Period:   time.Second,
	})
	if err != nil {
		log.Fatal(err)
	}

	res, err := limiter.Allow(context.Background(), "user:42")
	if err != nil {
		log.Printf("rate limiter unavailable: %v", err)
		return
	}
	if !res.Allowed {
		fmt.Printf("slow down, retry in %v\n", res.RetryAfter)
		return
	}
	fmt.Printf("ok, %d requests left\n", res.Remaining)
}

func (*TokenBucket) Allow

func (tb *TokenBucket) Allow(ctx context.Context, key string) (Result, error)

Allow implements Limiter.

func (*TokenBucket) AllowN

func (tb *TokenBucket) AllowN(ctx context.Context, key string, n int) (Result, error)

AllowN implements Limiter.

func (*TokenBucket) Reset

func (b *TokenBucket) Reset(ctx context.Context, key string) error

Reset implements Limiter.

type TokenBucketConfig

type TokenBucketConfig struct {
	// Capacity is the bucket size: the largest burst allowed at once.
	Capacity int

	// Rate is how many tokens are added back every Period.
	Rate int

	// Period is the refill interval for Rate. Defaults to one second.
	Period time.Duration
}

TokenBucketConfig configures a TokenBucket.

Directories

Path Synopsis
examples
gateway command
Command gateway is a small rate limited reverse proxy built on the ratelimit package.
Command gateway is a small rate limited reverse proxy built on the ratelimit package.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL