Rate limiting
framework/ratelimit is the general-purpose HTTP rate limiter. It enforces a
per-key sliding window with lockout: up to MaxAttempts requests are
admitted inside Window; the next request trips a hard block lasting
BlockDuration during which every request for that key gets 429 with a
Retry-After header. This is the "at most N per period, then lock out" shape.
Use it for brute-force surfaces, write-heavy endpoints, and any route where a steady refill rate is the wrong model. For steady API throughput ("1 req/s, burst 60") use the token-bucket middleware instead; see Two limiters, two jobs.
Quickstart: throttle one route
import ( "net/http" "time" "github.com/DonaldMurillo/gofastr/framework/ratelimit" ) // At most 20 checkouts per IP per minute; the 21st trips a 5-minute lockout. checkoutLimit := ratelimit.NewLimiter(ratelimit.Config{ MaxAttempts: 20, Window: time.Minute, BlockDuration: 5 * time.Minute, }) mux.Handle("POST /api/checkout", checkoutLimit.Middleware()(checkoutHandler))
Middleware() keys on the client IP by default. Over-limit responses are
429 Too Many Requests with a Retry-After header (whole seconds); the body is
a plain rate limit exceeded.
Throttle by something other than IP
MiddlewareByKey takes a function that derives the bucket identity from the
request: API key, authenticated user id, route param, anything. This is the
escape hatch when per-IP is too coarse.
// Per API key, not per IP. byKey := ratelimit.NewLimiter(ratelimit.Config{ MaxAttempts: 100, Window: time.Hour, BlockDuration: time.Hour, }) mux.Use(byKey.MiddlewareByKey(func(r *http.Request) string { return r.Header.Get("X-Api-Key") }))
A nil key func falls back to the client IP, so Middleware() is just
MiddlewareByKey with the default extractor.
For agent traffic verified with Web Bot Auth,
framework.AgentRateLimitKey(trustXFF) keys by the verified agent's
identity and by client IP otherwise, so a signed crawler has its own
budget instead of sharing its egress address's.
Key functions
| Symbol | What it does |
|---|---|
ratelimit.NewLimiter(cfg Config) *Limiter | Construct a limiter; zero fields default to MaxAttempts=10, Window=15m, BlockDuration=30m. |
(*Limiter).Allow(key string) (bool, time.Duration) | Record one attempt for key; returns allowed + retry-after. Use the context form on HTTP paths. |
(*Limiter).AllowContext(ctx, key) | Same, observing request cancellation when a shared Store is set. |
(*Limiter).Admit(ctx, key) (bool, time.Duration, error) | AllowContext plus the shared store's error. A store error still denies; use it when a spent budget triggers an action (ending a session) that an outage must not. |
(*Limiter).Middleware() | func(http.Handler) http.Handler, keyed by client IP. |
(*Limiter).MiddlewareByKey(keyFunc) | Same, keyed by a custom extractor. |
ratelimit.ClientIP(r, trustXFF) | IP extractor for a single proxy tier; honours X-Forwarded-For (its rightmost entry) only when trustXFF is true. |
(*Limiter).ClientIP(r) | The limiter's own extractor: TrustForwardedFor plus TrustedProxies. |
(*Limiter).ClientKey(r) | The per-client key Middleware() and the auth battery's per-IP limiters use: ClientIP with an IPv6 address collapsed to its /64 (ratelimit.IPKey). |
ratelimit.IPKey(addr) | An IPv4 address (IPv4-mapped included) as itself, an IPv6 address as its /64 prefix. |
Config fields: MaxAttempts, Window, BlockDuration, TrustForwardedFor,
TrustedProxies, Store, Scope, DevMode.
Two limiters, two jobs
GoFastr ships two limiters with deliberately different semantics. Pick by the question you are answering:
| Question | Use |
|---|---|
| "At most N per period, then block" (login, checkout, password reset) | framework/ratelimit (this package) |
| "Sustained rate with a burst" (public API throughput, "1 req/s, burst 60") | core/middleware.RateLimit (token bucket) |
The token-bucket middleware also emits the IETF-draft RateLimit-Limit /
RateLimit-Remaining / RateLimit-Reset budget headers so well-behaved API
clients can self-pace. This package emits only Retry-After: a live
remaining-attempt count on a lockout-style limiter would hand an attacker exact
brute-force pacing information on security-sensitive routes, and adds nothing
useful elsewhere.
The auth battery already uses it
battery/auth builds every built-in brute-force limiter on top of this
limiter: login (per-IP and per-account), register, 2FA challenge,
magic-link send, password-reset, email-verification. Those limiters are configured
through AuthConfig (LoginRateLimit, RegisterRateLimit, …) and apply
automatically when you mount the auth battery; you do not wire them by hand.
See security.md → "Rate limiting" for the auth-specific posture,
and the auth source for the per-endpoint scopes.
For your own non-auth routes, use this package directly as shown in the quickstart above.
Per-replica vs. shared budget
With a nil Config.Store the window lives in process memory, so the budget
is per replica: N replicas each admit MaxAttempts. That is the right
default for a single instance and for routes where per-replica throttling is
acceptable.
For a replica-wide (or fleet-wide) budget, so a coordinated burst across
replicas can't multiply the limit by N, supply a Store.
battery/auth.SQLRateLimitStore is the reference implementation (SQLite or
PostgreSQL); one store instance backs many limiters, with keys namespaced by
Config.Scope so a login limiter and a checkout limiter sharing one store never
collide. A key longer than 256 bytes, or one that is not valid UTF-8 or holds a
NUL byte (a client-sent X-Api-Key can be either), reaches the store as
sha256:<hex>, so one odd header cannot read as a store outage:
shared := auth.NewSQLRateLimitStore(db, "rate_limits") // One store, two independent budgets: loginLimit := ratelimit.NewLimiter(ratelimit.Config{ MaxAttempts: 10, Window: time.Minute, BlockDuration: 15 * time.Minute, Store: shared, Scope: "login_ip", }) checkoutLimit := ratelimit.NewLimiter(ratelimit.Config{ MaxAttempts: 20, Window: time.Minute, BlockDuration: 5 * time.Minute, Store: shared, Scope: "checkout", })
On a store error the limiter fails closed (denies); degrading the backend
must never lift the limit. A custom Redis/etcd backend only needs to satisfy the
ratelimit.Store interface.
The budget holds under concurrency. SQLRateLimitStore records each attempt
before it counts, so a burst on one key admits at most MaxAttempts callers
across every replica; when the whole burst lands at once it can admit fewer,
and the key blocks either way. A custom Store must keep the same property: a
count followed by a separate insert lets every caller in a burst read the same
pre-insert count. Record first and count second, or use an atomic counter.
IPv6 clients share a /64
A subscriber is routinely handed a whole IPv6 /64, 2^64 addresses, so
keying on the full address let one client send each request from a fresh
source address and never meet a per-IP limit. Middleware() and the auth
battery's per-IP limiters key IPv6 by its /64 instead
((*Limiter).ClientKey), and so do core/middleware.RateLimit's default
and proxy-aware keys and webbotauth.RateLimitKey(nil). Several people on one IPv6 LAN share a budget
then, the way people behind one IPv4 NAT already do. ClientIP still
returns the full address, for logs and audit rows.
X-Forwarded-For and proxies
ClientIP (and therefore the default Middleware()) ignores
X-Forwarded-For unless Config.TrustForwardedFor is true. A client talking
directly to the origin can put any value in that header; trusting it
unconditionally would let one curl with a rotating X-Forwarded-For bypass
every per-IP limit. Enable TrustForwardedFor only behind a reverse proxy
you control.
The header is read from the right. A proxy that appends (nginx
$proxy_add_x_forwarded_for, Go's httputil.ReverseProxy, most CDNs) keeps
whatever the client sent on the left and writes the address it saw on the
right, so the leftmost entry is client-chosen and never picks the bucket.
- One proxy tier:
TrustForwardedFor: trueis enough. The rightmost entry is the client. - More than one tier (a CDN in front of a load balancer, say): also list every
tier in
TrustedProxies(IPs or CIDRs). Only a request whose TCP peer is in the list may speak for the header, listed hops are skipped, and the key is the first hop no tier vouches past. Leave a tier out and the key lands on that tier's address, so all its clients share one bucket.
core/middleware.RateLimit (TrustProxyHeaders + TrustedProxies) and the
battery/log access log's remote field read the header the same way.
DevMode
Config.DevMode: true makes AllowContext admit every attempt without touching
either backend: a relief valve for local screenshot / verification tooling that
hammers an endpoint from localhost. Production must never set it; the default
keeps the limiter fail-closed.
Common mistakes
- Reaching for the token bucket when you mean "N then lock out".
core/middleware.RateLimitrefills continuously, so a brute-force attacker never gets a hard block: they just slow to the refill rate. Use this package for lockout semantics. - Trusting
X-Forwarded-Forwithout a stripping proxy. A rotating XFF header gives the attacker a fresh bucket per request. LeaveTrustForwardedForfalse unless you sit behind a proxy you control. - Counting on a fleet-wide budget with the in-memory store. With no
Store, each replica admits the fullMaxAttemptsindependently. Plug in a sharedStorewhen the budget must hold across replicas. - Keying a public limiter on attacker-chosen input without a cap. This package bounds its key map (idle keys are reclaimed, then soonest-expiring blocks under flood), but a custom key func over untrusted input (raw email, URL) still widens the key space; prefer stable identities (user id, hashed API key).
- Expecting budget headers. This limiter emits only
Retry-Afterby design (see Two limiters, two jobs). If a client needsRateLimit-Remainingto self-pace, that route wants the token bucket.