Rate Limiter Implementations (Go & Python)

Four algorithms from scratch — see system-design/rate-limiting.md for the conceptual/distributed-systems treatment. This file is the from-scratch, single-process, interview-whiteboard version of each, in both Go and Python, plus an HTTP middleware wrapper.

0/0 checks

1. Token Bucket

Bucket refills continuously at rate tokens/sec up to capacity; each request consumes 1 token.

package ratelimit
import (
	"sync"
	"time"
)
// TokenBucket allows bursts up to capacity, then throttles to the refill rate.
type TokenBucket struct {
	mu         sync.Mutex
	capacity   float64
	tokens     float64
	refillRate float64 // tokens per second
	lastRefill time.Time
}
func NewTokenBucket(capacity float64, refillRate float64) *TokenBucket {
	return &TokenBucket{
		capacity:   capacity,
		tokens:     capacity, // start full
		refillRate: refillRate,
		lastRefill: time.Now(),
	}
}
// Allow reports whether a single request may proceed right now.
func (b *TokenBucket) Allow() bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	now := time.Now()
	elapsed := now.Sub(b.lastRefill).Seconds()
	b.tokens = min(b.capacity, b.tokens+elapsed*b.refillRate)
	b.lastRefill = now
	if b.tokens < 1 {
		return false
	}
	b.tokens--
	return true
}
func min(a, b float64) float64 {
	if a < b {
		return a
	}
	return b
}
import threading
import time
class TokenBucket:
    """Allows bursts up to capacity, then throttles to the refill rate."""
    def __init__(self, capacity: float, refill_rate: float) -> None:
        self._lock = threading.Lock()
        self.capacity = capacity
        self.tokens = capacity  # start full
        self.refill_rate = refill_rate  # tokens per second
        self.last_refill = time.monotonic()
    def allow(self) -> bool:
        """Return True if a single request may proceed right now."""
        with self._lock:
            now = time.monotonic()
            elapsed = now - self.last_refill
            self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
            self.last_refill = now
            if self.tokens < 1:
                return False
            self.tokens -= 1
            return True
# asyncio variant: swap threading.Lock for asyncio.Lock and `allow` for an
# async def if callers are already inside an event loop instead of threads --
# the refill math is identical, only the mutual-exclusion primitive changes.
package ratelimit;
import java.util.concurrent.locks.ReentrantLock;
// TokenBucket allows bursts up to capacity, then throttles to the refill rate.
// Implements Limiter (defined below in the HTTP Middleware Wrapper section)
// explicitly -- unlike Go's implicit interface satisfaction or Python's
// structural Protocol above, Java requires "implements" up front.
public class TokenBucket implements Limiter {
    private final ReentrantLock lock = new ReentrantLock();
    private final double capacity;
    private double tokens;
    private final double refillRate; // tokens per second
    private long lastRefillNanos;
    public TokenBucket(double capacity, double refillRate) {
        this.capacity = capacity;
        this.tokens = capacity; // start full
        this.refillRate = refillRate;
        this.lastRefillNanos = System.nanoTime();
    }
    // allow reports whether a single request may proceed right now.
    @Override
    public boolean allow() {
        lock.lock();
        try {
            long now = System.nanoTime();
            double elapsed = (now - lastRefillNanos) / 1_000_000_000.0;
            tokens = Math.min(capacity, tokens + elapsed * refillRate);
            lastRefillNanos = now;
            if (tokens < 1) {
                return false;
            }
            tokens -= 1;
            return true;
        } finally {
            lock.unlock();
        }
    }
}
// Uses a ReentrantLock (java.util.concurrent.locks) as the direct analog of
// Go's sync.Mutex / Python's threading.Lock -- refill-then-check-then-consume
// is a compound operation needing one critical section, not a single atomic
// counter update, so a bare AtomicLong/AtomicDouble CAS loop wouldn't suffice.

Every Allow() call does two things in order — refill based on elapsed time, then check-and-consume:

graph TD
    classDef step fill:#3498db,stroke:#2471a3,color:#fff
    classDef ok fill:#27ae60,stroke:#1e8449,color:#fff
    classDef bad fill:#e74c3c,stroke:#c0392b,color:#fff

    A["Request arrives"] --> B["Refill: tokens = min(capacity, tokens + elapsed * rate)"]:::step
    B --> C{"tokens >= 1?"}
    C -->|"yes"| D["tokens -= 1, allow request"]:::ok
    C -->|"no"| E["reject request (429)"]:::bad

A client that's been idle for a while sends 10 requests in the same instant against a bucket with capacity 5. How many get through, and why not more or fewer?

Burst Scenario, Step by Step

A bucket with capacity=5, refill_rate=1 token/sec, starting full, hit by a burst of 7 requests arriving back-to-back (elapsed time ≈ 0 between them):

1. Start. Bucket is full: tokens = 5.00 (capacity 5, refill rate 1/sec). No requests yet.
2. Request #1 arrives. Refill adds ~0 tokens (elapsed ≈ 0). tokens = 5.00 >= 1 → allowed. tokens drops to 4.00.
3. Request #2. tokens = 4.00 >= 1 → allowed. tokens drops to 3.00.
4. Request #3. tokens = 3.00 >= 1 → allowed. tokens drops to 2.00.
5. Request #4. tokens = 2.00 >= 1 → allowed. tokens drops to 1.00.
6. Request #5. tokens = 1.00 >= 1 → allowed. tokens drops to 0.00. The whole burst allowance (capacity 5) is now spent.
7. Request #6 — REJECTED. tokens = 0.00 < 1Allow() returns false, client gets a 429. No headroom left this instant.
8. One second later, request #7 arrives. Refill adds elapsed * rate = 1 * 1 = 1.00 token → tokens = 1.00 >= 1 → allowed. tokens drops back to 0.00. The client is now fully throttled to the 1/sec refill rate — it can send exactly one more request per second, no more bursting until it goes idle long enough to refill.

Try It Yourself: Live Token Bucket

Same numbers as the walkthrough above (capacity 5, refills at 1 token/sec) — except this one runs on the real clock. Click "Send request" fast to burn through the burst allowance, then watch it get throttled; leave it alone for a few seconds and watch the gauge refill on its own.


2. Leaky Bucket

Requests join a fixed-size queue; a background process drains (processes) at a fixed rate regardless of arrival rate. Shapes bursts into uniform output instead of passing them through.

package ratelimit
import (
	"sync"
	"time"
)
// LeakyBucket queues requests and lets them "leak" out at a fixed rate.
// Unlike TokenBucket, it smooths bursts rather than passing them through.
type LeakyBucket struct {
	mu       sync.Mutex
	capacity int           // max queued requests
	queue    int           // current queue depth
	leakRate time.Duration // time between each leak (1 request drained)
	lastLeak time.Time
}
func NewLeakyBucket(capacity int, leakRate time.Duration) *LeakyBucket {
	return &LeakyBucket{
		capacity: capacity,
		leakRate: leakRate,
		lastLeak: time.Now(),
	}
}
// Allow reports whether the request can be queued (accepted into the bucket).
// It does NOT mean the request is processed immediately — only that it was
// admitted to the queue for eventual draining at leakRate.
func (b *LeakyBucket) Allow() bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.drain(time.Now())
	if b.queue >= b.capacity {
		return false // queue full, reject
	}
	b.queue++
	return true
}
// drain removes completed "leaks" based on elapsed time since lastLeak.
func (b *LeakyBucket) drain(now time.Time) {
	elapsed := now.Sub(b.lastLeak)
	leaked := int(elapsed / b.leakRate)
	if leaked <= 0 {
		return
	}
	if leaked > b.queue {
		leaked = b.queue
	}
	b.queue -= leaked
	b.lastLeak = b.lastLeak.Add(time.Duration(leaked) * b.leakRate)
}
import threading
import time
class LeakyBucket:
    """Queues requests and lets them "leak" out at a fixed rate.
    Unlike TokenBucket, it smooths bursts rather than passing them through.
    """
    def __init__(self, capacity: int, leak_rate: float) -> None:
        """leak_rate: seconds between each leak (1 request drained)."""
        self._lock = threading.Lock()
        self.capacity = capacity
        self.queue = 0  # current queue depth
        self.leak_rate = leak_rate
        self.last_leak = time.monotonic()
    def allow(self) -> bool:
        """Return True if the request can be queued (admitted to the bucket).
        Does NOT mean the request is processed immediately -- only that it
        was admitted to the queue for eventual draining at leak_rate.
        """
        with self._lock:
            self._drain(time.monotonic())
            if self.queue >= self.capacity:
                return False  # queue full, reject
            self.queue += 1
            return True
    def _drain(self, now: float) -> None:
        """Remove completed "leaks" based on elapsed time since last_leak."""
        elapsed = now - self.last_leak
        leaked = int(elapsed / self.leak_rate)
        if leaked <= 0:
            return
        leaked = min(leaked, self.queue)
        self.queue -= leaked
        self.last_leak += leaked * self.leak_rate
package ratelimit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
// LeakyBucket queues requests and lets them "leak" out at a fixed rate.
// Unlike TokenBucket, it smooths bursts rather than passing them through.
public class LeakyBucket implements Limiter {
    private final ReentrantLock lock = new ReentrantLock();
    private final int capacity; // max queued requests
    private final AtomicInteger queue = new AtomicInteger(0); // current queue depth
    private final long leakRateNanos; // time between each leak (1 request drained)
    private long lastLeakNanos;
    public LeakyBucket(int capacity, long leakRateMillis) {
        this.capacity = capacity;
        this.leakRateNanos = leakRateMillis * 1_000_000L;
        this.lastLeakNanos = System.nanoTime();
    }
    // allow reports whether the request can be queued (accepted into the bucket).
    // It does NOT mean the request is processed immediately -- only that it was
    // admitted to the queue for eventual draining at leakRate.
    @Override
    public boolean allow() {
        lock.lock();
        try {
            drain(System.nanoTime());
            if (queue.get() >= capacity) {
                return false; // queue full, reject
            }
            queue.incrementAndGet();
            return true;
        } finally {
            lock.unlock();
        }
    }
    // drain removes completed "leaks" based on elapsed time since lastLeakNanos.
    private void drain(long now) {
        long elapsed = now - lastLeakNanos;
        int leaked = (int) (elapsed / leakRateNanos);
        if (leaked <= 0) {
            return;
        }
        int current = queue.get();
        if (leaked > current) {
            leaked = current;
        }
        queue.addAndGet(-leaked);
        lastLeakNanos += leaked * leakRateNanos;
    }
}
// queue is an AtomicInteger purely so reads outside the lock (metrics,
// health checks) stay tear-free -- the compound drain+check+increment
// inside allow() still runs under the ReentrantLock for correctness.

Admission and draining are two separate concerns — a request can be queued immediately while still being processed much later, smoothly:

graph TD
    classDef step fill:#3498db,stroke:#2471a3,color:#fff
    classDef ok fill:#27ae60,stroke:#1e8449,color:#fff
    classDef bad fill:#e74c3c,stroke:#c0392b,color:#fff

    A["Request arrives"] --> B["Drain: leaked = elapsed / leakRate, queue -= leaked"]:::step
    B --> C{"queue < capacity?"}
    C -->|"yes"| D["queue += 1, request admitted"]:::ok
    C -->|"no"| E["queue full, reject (429)"]:::bad
    D --> F["Background drain keeps releasing<br/>one request at a time, at leakRate"]:::step

Both token bucket and leaky bucket can "allow" a burst of requests into the system. What's different about what happens to that burst afterward?


3. Fixed Window Counter

Simplest algorithm. Counts requests in discrete, non-overlapping time windows. Vulnerable to a 2x burst at window boundaries (see comparison table).

package ratelimit
import (
	"sync"
	"time"
)
// FixedWindow counts requests per discrete time window.
type FixedWindow struct {
	mu          sync.Mutex
	limit       int
	windowSize  time.Duration
	windowStart time.Time
	count       int
}
func NewFixedWindow(limit int, windowSize time.Duration) *FixedWindow {
	return &FixedWindow{
		limit:       limit,
		windowSize:  windowSize,
		windowStart: time.Now(),
	}
}
func (w *FixedWindow) Allow() bool {
	w.mu.Lock()
	defer w.mu.Unlock()
	now := time.Now()
	if now.Sub(w.windowStart) >= w.windowSize {
		// New window: reset counter and boundary.
		w.windowStart = now
		w.count = 0
	}
	if w.count >= w.limit {
		return false
	}
	w.count++
	return true
}
import threading
import time
class FixedWindow:
    """Counts requests per discrete time window."""
    def __init__(self, limit: int, window_size: float) -> None:
        self._lock = threading.Lock()
        self.limit = limit
        self.window_size = window_size
        self.window_start = time.monotonic()
        self.count = 0
    def allow(self) -> bool:
        with self._lock:
            now = time.monotonic()
            if now - self.window_start >= self.window_size:
                # New window: reset counter and boundary.
                self.window_start = now
                self.count = 0
            if self.count >= self.limit:
                return False
            self.count += 1
            return True
package ratelimit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
// FixedWindow counts requests per discrete time window.
public class FixedWindow implements Limiter {
    private final ReentrantLock lock = new ReentrantLock();
    private final int limit;
    private final long windowSizeNanos;
    private long windowStartNanos;
    private final AtomicInteger count = new AtomicInteger(0);
    public FixedWindow(int limit, long windowSizeMillis) {
        this.limit = limit;
        this.windowSizeNanos = windowSizeMillis * 1_000_000L;
        this.windowStartNanos = System.nanoTime();
    }
    @Override
    public boolean allow() {
        lock.lock();
        try {
            long now = System.nanoTime();
            if (now - windowStartNanos >= windowSizeNanos) {
                // New window: reset counter and boundary.
                windowStartNanos = now;
                count.set(0);
            }
            if (count.get() >= limit) {
                return false;
            }
            count.incrementAndGet();
            return true;
        } finally {
            lock.unlock();
        }
    }
}

The boundary problem: each window resets its counter independently, so a burst timed right around the edge can slip two windows' worth of traffic past in a much shorter span than windowSize:

graph LR
    classDef window fill:#3498db,stroke:#2471a3,color:#fff
    classDef burst fill:#e74c3c,stroke:#c0392b,color:#fff

    subgraph W1["Window 1 -- count resets to 0 at windowStart, climbs to the limit"]
        R1["requests trickle in all window,<br/>count reaches limit right at the end"]:::window
    end
    subgraph W2["Window 2 -- new windowStart, count resets to 0 again"]
        R2["counter starts over at 0,<br/>even though W1 just ended at the limit"]:::window
    end
    B["Requests clustered right at the boundary can<br/>land close to 2x the limit in a short span"]:::burst
    R1 -->|"last requests of W1, just before the boundary"| B
    R2 -->|"first requests of W2, just after the boundary"| B

Fixed window's own limitation is called out as "vulnerable to a 2x burst at window boundaries." Why 2x specifically, rather than 3x or some unbounded multiple?


4. Sliding Window Log

Stores the timestamp of every accepted request; on each check, prunes timestamps older than now - window and counts what remains. Exact accuracy, O(n) memory per key.

package ratelimit
import (
	"sync"
	"time"
)
// SlidingWindowLog is exact (no boundary burst) but stores one timestamp
// per request within the window — memory scales with request volume.
type SlidingWindowLog struct {
	mu         sync.Mutex
	limit      int
	windowSize time.Duration
	timestamps []time.Time
}
func NewSlidingWindowLog(limit int, windowSize time.Duration) *SlidingWindowLog {
	return &SlidingWindowLog{
		limit:      limit,
		windowSize: windowSize,
		timestamps: make([]time.Time, 0, limit),
	}
}
func (s *SlidingWindowLog) Allow() bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	now := time.Now()
	cutoff := now.Add(-s.windowSize)
	s.timestamps = pruneBefore(s.timestamps, cutoff)
	if len(s.timestamps) >= s.limit {
		return false
	}
	s.timestamps = append(s.timestamps, now)
	return true
}
// pruneBefore drops timestamps older than cutoff. Timestamps are appended
// in increasing order, so the surviving slice is always a suffix — this
// is O(k) where k is the number of expired entries, not a full O(n) scan
// with allocation per call.
func pruneBefore(ts []time.Time, cutoff time.Time) []time.Time {
	i := 0
	for i < len(ts) && ts[i].Before(cutoff) {
		i++
	}
	return ts[i:]
}
import threading
import time
from collections import deque
class SlidingWindowLog:
    """Exact (no boundary burst) but stores one timestamp per request
    within the window -- memory scales with request volume.
    """
    def __init__(self, limit: int, window_size: float) -> None:
        self._lock = threading.Lock()
        self.limit = limit
        self.window_size = window_size
        self.timestamps: deque[float] = deque()
    def allow(self) -> bool:
        with self._lock:
            now = time.monotonic()
            cutoff = now - self.window_size
            self._prune_before(cutoff)
            if len(self.timestamps) >= self.limit:
                return False
            self.timestamps.append(now)
            return True
    def _prune_before(self, cutoff: float) -> None:
        """Drop timestamps older than cutoff.
        Timestamps are appended in increasing order, so the surviving
        entries are always a suffix -- popleft() from a deque is O(1) per
        expired entry, not a full O(n) rebuild with allocation per call.
        """
        while self.timestamps and self.timestamps[0] < cutoff:
            self.timestamps.popleft()
package ratelimit;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
// SlidingWindowLog is exact (no boundary burst) but stores one timestamp
// per request within the window -- memory scales with request volume.
public class SlidingWindowLog implements Limiter {
    private final ReentrantLock lock = new ReentrantLock();
    private final int limit;
    private final long windowSizeNanos;
    private final ConcurrentLinkedDeque<Long> timestamps = new ConcurrentLinkedDeque<>();
    private final AtomicInteger size = new AtomicInteger(0);
    public SlidingWindowLog(int limit, long windowSizeMillis) {
        this.limit = limit;
        this.windowSizeNanos = windowSizeMillis * 1_000_000L;
    }
    @Override
    public boolean allow() {
        lock.lock();
        try {
            long now = System.nanoTime();
            long cutoff = now - windowSizeNanos;
            pruneBefore(cutoff);
            if (size.get() >= limit) {
                return false;
            }
            timestamps.addLast(now);
            size.incrementAndGet();
            return true;
        } finally {
            lock.unlock();
        }
    }
    // pruneBefore drops timestamps older than cutoff. Timestamps are appended
    // in increasing order, so the surviving deque is always a suffix -- this
    // is O(k) where k is the number of expired entries, not a full O(n) scan
    // with allocation per call.
    private void pruneBefore(long cutoff) {
        Long head;
        while ((head = timestamps.peekFirst()) != null && head < cutoff) {
            timestamps.pollFirst();
            size.decrementAndGet();
        }
    }
}
// ConcurrentLinkedDeque gives lock-free peekFirst()/pollFirst()/addLast(),
// but the overall prune-then-check-then-append sequence still needs the
// ReentrantLock -- otherwise two threads could both pass the size check
// before either appends, admitting one request too many past the limit.

No reset boundary at all — the window continuously slides with now, so every check re-evaluates the true count of requests in the trailing windowSize interval:

graph TD
    classDef step fill:#3498db,stroke:#2471a3,color:#fff
    classDef ok fill:#27ae60,stroke:#1e8449,color:#fff
    classDef bad fill:#e74c3c,stroke:#c0392b,color:#fff

    A["Request arrives at time now"] --> B["cutoff = now - windowSize"]:::step
    B --> C["Prune: drop every stored timestamp older than cutoff"]:::step
    C --> D{"len(timestamps) < limit?"}
    D -->|"yes"| E["append now, allow request"]:::ok
    D -->|"no"| F["reject request (429)"]:::bad

Sliding window log is described as "exact accuracy, O(n) memory per key." What does the "n" actually count, and why does that make it a bad fit for a high-volume key?


Comparison Table

Algorithm Memory Accuracy Burst handling Best for
Token bucket O(1) per key High Allows bursts up to capacity, then throttles to rate API gateways, general-purpose default
Leaky bucket O(1) per key (just a counter, not a real queue in this impl) High Smooths bursts to a uniform output rate — no burst passes through Traffic shaping, protecting a fixed-capacity downstream (e.g. a DB)
Fixed window O(1) per key Low Up to 2x limit can pass across a window boundary Simple, non-critical counters where boundary burst is tolerable
Sliding window log O(n) per key (n = requests in window) Exact None — hard cutoff, no boundary effect Low-volume, high-precision limits (e.g. audit-sensitive admin APIs)

Same burst of requests, same instant, hits all four implementations above with equivalent limits. Which ones let the burst through immediately, and which one(s) don't?


HTTP Middleware Wrapper

Any of the four implementations satisfy the same Limiter interface, so the middleware is decoupled from the algorithm choice.

package ratelimit
import "net/http"
// Limiter is satisfied by TokenBucket, LeakyBucket, FixedWindow, and
// SlidingWindowLog — any algorithm above can be plugged into Middleware.
type Limiter interface {
	Allow() bool
}
// Middleware wraps an http.Handler, rejecting requests with 429 when the
// underlying Limiter denies them. In production this would key limiters
// per-client (see PerClientMiddleware below) rather than share one global
// limiter across all traffic.
func Middleware(limiter Limiter, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !limiter.Allow() {
			w.Header().Set("Retry-After", "1")
			http.Error(w, "429 Too Many Requests", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}
from typing import Callable, Protocol
class Limiter(Protocol):
    """Satisfied by TokenBucket, LeakyBucket, FixedWindow, and
    SlidingWindowLog -- any algorithm above can be plugged into the
    middleware below.
    """
    def allow(self) -> bool: ...
def rate_limit_middleware(limiter: Limiter, app: Callable) -> Callable:
    """WSGI middleware: reject requests with 429 when the underlying
    Limiter denies them.
    In production this would key limiters per-client (see
    PerClientMiddleware below) rather than share one global limiter
    across all traffic.
    """
    def wrapped_app(environ, start_response):
        if not limiter.allow():
            start_response(
                "429 Too Many Requests",
                [("Content-Type", "text/plain"), ("Retry-After", "1")],
            )
            return [b"429 Too Many Requests"]
        return app(environ, start_response)
    return wrapped_app
# Flask equivalent, same decision, different plumbing:
#
#   @app.before_request
#   def check_rate_limit():
#       if not limiter.allow():
#           resp = make_response("429 Too Many Requests", 429)
#           resp.headers["Retry-After"] = "1"
#           return resp
// File: Limiter.java
package ratelimit;
// Limiter is satisfied by TokenBucket, LeakyBucket, FixedWindow, and
// SlidingWindowLog -- any algorithm above can be plugged into RateLimitFilter.
// Java needs an explicit "implements Limiter" on each class (see above);
// there's no structural typing the way Go's interfaces or Python's Protocol
// give you for free.
public interface Limiter {
    boolean allow();
}
// File: RateLimitFilter.java
package ratelimit;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
// RateLimitFilter wraps any Limiter as a jakarta.servlet.Filter, rejecting
// requests with 429 when the underlying Limiter denies them. In production
// this would key limiters per-client (see PerClientFilter below) rather than
// share one global limiter across all traffic. Uses jakarta.servlet (Jakarta
// EE 9+ / Servlet 6.0, the namespace shipped by Tomcat 10+ and Spring Boot
// 3+) rather than the legacy javax.servlet package -- pick whichever matches
// your container, the doFilter logic below is identical either way.
public class RateLimitFilter implements Filter {
    private final Limiter limiter;
    public RateLimitFilter(Limiter limiter) {
        this.limiter = limiter;
    }
    @Override
    public void init(FilterConfig filterConfig) {
        // No setup needed -- limiter is already constructed.
    }
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        if (!limiter.allow()) {
            HttpServletResponse httpResponse = (HttpServletResponse) response;
            httpResponse.setHeader("Retry-After", "1");
            httpResponse.sendError(429, "429 Too Many Requests"); // no SC_TOO_MANY_REQUESTS constant in the servlet API
            return;
        }
        chain.doFilter(request, response);
    }
    @Override
    public void destroy() {
        // No resources to release.
    }
}

Per-client variant (keyed by IP or API key)

package ratelimit
import (
	"net"
	"net/http"
	"sync"
)
// PerClientMiddleware maintains one Limiter per client key (e.g. IP address),
// lazily created on first request. limiterFactory lets the caller choose
// which of the four algorithms to instantiate per client.
type PerClientMiddleware struct {
	mu             sync.Mutex
	limiters       map[string]Limiter
	limiterFactory func() Limiter
}
func NewPerClientMiddleware(factory func() Limiter) *PerClientMiddleware {
	return &PerClientMiddleware{
		limiters:       make(map[string]Limiter),
		limiterFactory: factory,
	}
}
func (m *PerClientMiddleware) Wrap(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		key := clientKey(r)
		m.mu.Lock()
		limiter, ok := m.limiters[key]
		if !ok {
			limiter = m.limiterFactory()
			m.limiters[key] = limiter
		}
		m.mu.Unlock()
		if !limiter.Allow() {
			w.Header().Set("Retry-After", "1")
			http.Error(w, "429 Too Many Requests", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}
func clientKey(r *http.Request) string {
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		return r.RemoteAddr
	}
	return host
}
// Example wiring:
//
//   mw := NewPerClientMiddleware(func() Limiter {
//       return NewTokenBucket(20, 5) // burst 20, refill 5/sec, per client
//   })
//   http.Handle("/api/", mw.Wrap(apiHandler))
//   http.ListenAndServe(":8080", nil)
//
// Note: limiters map above grows unbounded as new clients appear — a
// production version needs an eviction policy (e.g. LRU from lru-cache.md,
// or a TTL sweep) to bound memory under a churn of unique client keys.
import threading
from typing import Callable, Dict
class PerClientMiddleware:
    """Maintains one Limiter per client key (e.g. IP address), lazily
    created on first request. limiter_factory lets the caller choose
    which of the four algorithms to instantiate per client.
    """
    def __init__(self, limiter_factory: Callable[[], "Limiter"]) -> None:
        self._lock = threading.Lock()
        self.limiters: Dict[str, "Limiter"] = {}
        self.limiter_factory = limiter_factory
    def wrap(self, app: Callable) -> Callable:
        def wrapped_app(environ, start_response):
            key = self._client_key(environ)
            with self._lock:
                limiter = self.limiters.get(key)
                if limiter is None:
                    limiter = self.limiter_factory()
                    self.limiters[key] = limiter
            if not limiter.allow():
                start_response(
                    "429 Too Many Requests",
                    [("Content-Type", "text/plain"), ("Retry-After", "1")],
                )
                return [b"429 Too Many Requests"]
            return app(environ, start_response)
        return wrapped_app
    @staticmethod
    def _client_key(environ) -> str:
        return environ.get("REMOTE_ADDR", "unknown")
# Example wiring:
#
#   mw = PerClientMiddleware(lambda: TokenBucket(capacity=20, refill_rate=5))
#   app = mw.wrap(api_handler)  # burst 20, refill 5/sec, per client
#
# Note: limiters dict above grows unbounded as new clients appear -- a
# production version needs an eviction policy (e.g. LRU from lru-cache.md,
# or a TTL sweep) to bound memory under a churn of unique client keys.
package ratelimit;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
// PerClientFilter maintains one Limiter per client key (e.g. IP address),
// lazily created on first request. limiterFactory lets the caller choose
// which of the four algorithms to instantiate per client.
public class PerClientFilter implements Filter {
    private final Map<String, Limiter> limiters = new ConcurrentHashMap<>();
    private final Supplier<Limiter> limiterFactory;
    public PerClientFilter(Supplier<Limiter> limiterFactory) {
        this.limiterFactory = limiterFactory;
    }
    @Override
    public void init(FilterConfig filterConfig) {
        // No setup needed.
    }
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        String key = clientKey((HttpServletRequest) request);
        Limiter limiter = limiters.computeIfAbsent(key, k -> limiterFactory.get());
        if (!limiter.allow()) {
            HttpServletResponse httpResponse = (HttpServletResponse) response;
            httpResponse.setHeader("Retry-After", "1");
            httpResponse.sendError(429, "429 Too Many Requests"); // no SC_TOO_MANY_REQUESTS constant in the servlet API
            return;
        }
        chain.doFilter(request, response);
    }
    @Override
    public void destroy() {
        // No resources to release.
    }
    private static String clientKey(HttpServletRequest request) {
        return request.getRemoteAddr();
    }
}
// ConcurrentHashMap.computeIfAbsent gives lock-free-on-the-common-path,
// exactly-once-per-key construction of each client's Limiter -- the direct
// analog of Go's mutex-guarded map lookup and Python's lock-guarded dict.get.
// Example wiring (web.xml or Servlet 6.0 annotation-based registration):
//   PerClientFilter filter = new PerClientFilter(
//       () -> new TokenBucket(20, 5)); // burst 20, refill 5/sec, per client
//   FilterRegistration.Dynamic reg =
//       servletContext.addFilter("rateLimit", filter);
//   reg.addMappingForUrlPatterns(null, false, "/api/*");
// Note: limiters map above grows unbounded as new clients appear -- a
// production version needs an eviction policy (e.g. LRU from lru-cache.md,
// or a TTL sweep) to bound memory under a churn of unique client keys.