Hello, I'm Maneshwar. I'm building git-lrc, a Micro AI code reviewer that runs on every commit. It is free and source-available on Github.
Limiter gives you three methods, and the difference between them is the whole point of the package:
Allow()— returnstrueif a token is available right now, otherwisefalse. Non-blocking. Use this when you want to drop excess requests (e.g. return429 Too Many Requests).
Wait(ctx)— blocks until a token is available. Respects context cancellation. Use this for background workers that should slow down, not fail.
Reserve()— returns aReservationtelling you how long to wait. Use this when you want to make the decision yourself — for example, fail fast if the delay exceeds some threshold.
Drop-style: HTTP middleware with Allow()
The most common use case is "limit incoming HTTP requests and reject the overflow." Here is the middleware:
package main
import (
"encoding/json"
"net/http"
"golang.org/x/time/rate"
)
func rateLimitMiddleware(next http.Handler) http.Handler {
// 10 req/sec sustained, 20 burst
limiter := rate.NewLimiter(10, 20)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
json.NewEncoder(w).Encode(map[string]string{
"error": "rate limit exceeded, please retry later",
})
return
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello\n"))
})
http.ListenAndServe(":8080", rateLimitMiddleware(mux))
}
A few things to note. First, the janitor goroutine: without it the map grows forever as new IPs keep showing up.
Second, r.RemoteAddr is host:port, so if you use it as the map key directly, the same client on a different ephemeral port gets a fresh bucket — which is not what you want. net.SplitHostPort fixes that.
Third, in any real deployment behind a load balancer or CDN, even the host portion of RemoteAddr is your proxy's IP — you need to extract the client IP from X-Forwarded-For or X-Real-IP (and verify the proxy is trusted, or you have just made spoofing trivial).
Wait-style: throttling outbound calls
When you are the client hitting some upstream API with a 100 req/sec ceiling, you want to slow down, not error out.
Wait is the right tool here.
func fetchAll(ctx context.Context, urls []string) {
// ~100/sec, burst of 1 (no headroom beyond the steady rate)
limiter := rate.NewLimiter(rate.Every(10*time.Millisecond), 1)
var wg sync.WaitGroup
for _, u := range urls {
if err := limiter.Wait(ctx); err != nil {
break // context cancelled
}
wg.Add(1)
go func(u string) {
defer wg.Done()
fetch(u)
}(u)
}
wg.Wait()
}
If you run this, every iteration after the first will print roughly 10ms.
The limiter does not give you 100 in a burst and then make you wait — it spaces them out exactly.
That is the leaky bucket guarantee.
Slack: a controlled amount of burstiness
Pure leaky bucket can be too strict.
If your producer is slightly bursty by nature, you may not want every single hiccup to cause queueing.
Uber's library has a "slack" knob for this.
With slack, the limiter can accumulate a small number of unspent requests during idle periods and let you burn through them in a burst later.
Worth being precise about what slack is, because it is not the same thing as a token bucket's burst capacity.
A token bucket refills continuously up to its capacity, so even a steady stream of requests can build up headroom if it briefly outpaces the consumer.
Slack only accumulates during idle time — if you are calling Take() continuously, slack does nothing.
It is a one-shot "you went quiet, so we will let you catch up" allowance, not an ongoing buffer.
// Default: allows up to 10 slack tokens (small built-in burst tolerance after idle)
rl := ratelimit.New(100)
// Strict mode: zero slack, perfectly even spacing
rl := ratelimit.New(100, ratelimit.WithoutSlack)
// Custom slack
rl := ratelimit.New(100, ratelimit.WithSlack(50))
This is the "sliding log" variant — exact, but memory is O(limit × active_keys).
For high limits (say, 10k req/min across many keys), that becomes real memory.
The standard fix is the sliding window counter variant: keep two adjacent fixed windows (the current and previous minute), each with a single integer count, and estimate the rolling count as count_current + count_previous × (1 - elapsed_fraction_of_current_window).
You lose a bit of precision near window boundaries but drop from O(limit) per key to O(1).
For most real workloads, the simple log version above is fine; reach for the counter version when memory becomes a problem.
You also want a janitor here, same as the IP limiter, to evict keys nobody has hit in a while.
A word on distributed rate limiting
Everything above runs in a single process.
The moment you scale to two instances of your service behind a load balancer, your limits are effectively doubled — each instance has its own bucket.
For a single-instance side project this does not matter.
For anything serious, you need a shared store.
The standard answer is Redis.
The github.com/go-redis/redis_rate package implements GCRA (a leaky-bucket variant) on top of Redis with a single Lua script per check, which keeps it atomic and fast. Roughly:
limiter := redis_rate.NewLimiter(rdb)
res, _ := limiter.Allow(ctx, "user:42", redis_rate.PerSecond(10))
if res.Allowed == 0 {
// reject
}
I will cover the Redis side of this — including the Lua scripts, why GCRA wins over naive sliding windows at scale, and how to handle Redis going down — in a follow-up post.
For now, just know that when you outgrow single-node limiting, this is the next stop.
Which one should you use?
If you remember one thing from this post, remember this:
Default togolang.org/x/time/rate. It is well-tested, context-aware, and covers 90% of cases. UseAllowfor HTTP servers,Waitfor outbound clients.
Reach forgo.uber.org/ratelimitwhen you specifically need evenly spaced output and no bursts.
Roll a sliding window when your requirement is genuinely "no more than N in any rolling window of duration W" and average-rate enforcement is not enough.
The biggest mistake I see is people writing their own token bucket from scratch and getting the math subtly wrong — off-by-one on the burst size, races on the refill, drift over long runs. The libraries exist. Use them.
Now go put a 429 in front of whatever is currently melting your server.
/ | | | | |
git-lrc
Free, Micro AI Code Reviews That Run on Commit
SOCIAL SHARE CARD GENERATOR