Documentation
¶
Overview ¶
Package ratelimit is an in-memory, fixed-cost request limiter.
Four properties shaped it, and each is a trade rather than an oversight.
It is in-memory and per-instance **by default**, and that is the shape this package was built in. The surfaces being protected include the redirect path, whose entire budget is 20ms, so spending a network round trip to decide whether to allow a request would cost more than the limit saves. Redis is also optional at runtime by design, and a limiter that stops limiting when the cache goes away is worse than one whose numbers are per-instance. The consequence is stated rather than hidden: with N replicas the effective limit is N times the configured one.
**M24 added a shared mode, and this paragraph said otherwise until 0.2.0** (F38). `shared.go` — in this package — backs every limiter constructed with a Shared option (`limits.go` names them) with a Redis token bucket, shared across replicas and falling back to these in-memory buckets only when Redis does not answer. The 404-probe limiter is the one that stays plain, and `limits.go` says "deliberately not shared" beside it for the reason above: it guards the redirect path. So the per-instance multiplication described here is true of the 404 limiter, true of every limiter while Redis is unreachable, and not true of a Shared-backed limiter on a healthy instance.
IPv6 is keyed by /64, not by address. A single host is routinely handed a whole /64, so a per-address key would let one machine present an effectively unlimited number of identities — defeating the limit and growing the table without bound while doing it. /64 is a floor rather than the whole answer: a site delegated a shorter prefix, /56 or /48, holds 256 or 65536 distinct /64s and gets a bucket for each. Keying coarser than /64 would close that at the price F57 ruled out on the v4 side, where a wider key lets one abusive host throttle its neighbours.
It fails open. When the key table is full and a sweep cannot free room, the request is allowed and a counter increments. A limiter is abuse mitigation, not an authorization boundary; refusing real traffic because bookkeeping ran out of space would turn a memory ceiling into an outage.
It is a token bucket with lazy refill, so there is no timer per key and no background goroutine. Sweeping is amortized across calls, which means a limiter cannot outlive the thing that created it or leak a goroutine into a test binary.
Index ¶
- func Key(addr netip.Addr) string
- func RetryAfterSeconds(d time.Duration) int
- type Limiter
- func (l *Limiter) Allow(addr netip.Addr) (bool, time.Duration)
- func (l *Limiter) AllowKey(key string) (bool, time.Duration)
- func (l *Limiter) Charge(addr netip.Addr)
- func (l *Limiter) Check(addr netip.Addr) (bool, time.Duration)
- func (l *Limiter) Fallbacks() int64
- func (l *Limiter) Len() int
- func (l *Limiter) Overflows() int64
- func (l *Limiter) RefundKey(key string)
- type Options
- type Shared
- type SharedConfig
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Key ¶
Key folds an address to its rate-limiting identity: the full address for IPv4, the /64 prefix for IPv6.
The IPv6 case is the one that matters. Handing out /64s to single hosts is normal, so per-address keying would let one machine rotate through more identities than the table could ever hold — the limit would silently stop applying to precisely the client working hardest to evade it. It does not follow that a /64 is one customer: a site delegated a /56 or a /48 keeps a key per /64 inside it, which the package comment explains is deliberate.
func RetryAfterSeconds ¶
RetryAfterSeconds renders a wait as an HTTP Retry-After value.
Rounded up, with a floor of 1: Retry-After: 0 invites an immediate retry that is certain to be throttled again, and a client honouring it politely would hammer the endpoint it was just asked to back off from.
Types ¶
type Limiter ¶
type Limiter struct {
// contains filtered or unexported fields
}
Limiter allows or throttles requests by client address.
Every method is nil-safe, and a nil Limiter allows everything. That is what makes "0 disables this limit" a single check at construction rather than a branch at every call site.
func New ¶
New returns a limiter allowing perMinute requests per key per minute, or nil if perMinute is zero or negative.
Returning nil for a disabled limit is deliberate: the caller stores it, every method tolerates it, and there is no second "enabled" flag to keep in sync with the number.
func (*Limiter) Allow ¶
Allow consumes one token, reporting whether the request may proceed and, if not, how long until it could.
func (*Limiter) AllowKey ¶ added in v0.2.0
AllowKey is Allow against something that is not an address.
Added by M35 for the one limit that has to be keyed on the *resource* rather than on the client: guesses at a link's password, driven through many visitors' browsers, spread across as many addresses as there are visitors and slip under a per-address bucket entirely (D54). Keying the same limiter on the alias closes that, and the two limbs are checked together — an attacker has to stay under both.
Deliberately the same buckets, the same sweep and the same Redis script the address-keyed limit uses. A second mechanism would be a second thing to get wrong, and the shared limiter M24 built already takes a string key: `Key` was only ever how an address became one.
The caller is responsible for a key that cannot collide with an address — prefix it — because both live in one table.
func (*Limiter) Check ¶
Check reports whether a token is available without consuming one.
Paired with Charge by callers that only bill some outcomes — the redirect path checks before resolving an alias and charges only for a miss, so a working short link never spends a token.
func (*Limiter) Fallbacks ¶ added in v0.2.0
Fallbacks counts requests this limiter decided locally because the shared limiter did not answer.
Zero on a limiter with no shared backing, and zero on a shared one while Redis is healthy — which is what makes it readable: any movement means this replica is enforcing its own numbers rather than the instance's, and the configured limit has silently become per-replica.
It exists because the tracked-keys gauge cannot say this. A healthy shared limiter never writes its local table, so `Len()` reads zero and is indistinguishable from no traffic — an operator watching it could not tell a working shared limit from a fallen-back one, on the two limiters whose entire justification for a Redis round trip is that per-replica multiplication is unacceptable (F102).
func (*Limiter) Overflows ¶
Overflows reports how many requests were allowed because the table was full.
Worth a metric rather than a log line: a nonzero and climbing value means the limiter is no longer limiting, which is exactly the moment an operator wants to know without having to grep.
func (*Limiter) RefundKey ¶ added in v0.2.0
RefundKey hands one token back to a keyed bucket.
For a caller that has to spend before it knows whether it should have. The link-password gate is the one: both limbs are consumed before the form is parsed, deliberately, so that timing cannot say which limb refused — and that left a link with more than `burst` legitimate visitors in a burst throttling itself with no attacker present (F115). A correct password refunds the alias limb, which touches neither D53 nor D54: the per-alias keying that stops distributed guessing is unchanged, and what is given back is only ever a token spent by somebody who proved they had the password.
The address limb is deliberately **not** refunded. A visitor typing the right password is still traffic from that address, and the per-address limb is what bounds one machine grinding a wordlist.
Never above burst: the shared script clamps, and so does the local bucket.
type Options ¶
type Options struct {
// Burst is how many requests may arrive at once before throttling starts.
// Defaults to the per-minute rate, which lets a client spend its whole
// minute's allowance immediately — right for scripts that batch, and still
// bounded by the refill rate over any longer window.
Burst int
// MaxKeys bounds tracked keys across all shards. Zero means defaultMaxKeys.
MaxKeys int
// Now overrides the clock, for tests.
Now func() time.Time
// which is what every limit was before M24 and what the 404-probe limiter
// stays: sharing that one would put a network round trip on the redirect
// path and make an optional dependency load-bearing.
Shared *Shared
}
Options tunes a Limiter. The zero value is valid.
type Shared ¶ added in v0.2.0
type Shared struct {
// contains filtered or unexported fields
}
Shared makes a limit apply across replicas instead of per process.
In-memory buckets mean N replicas allow roughly N times the configured rate, and a restart resets every bucket. That is fine for the 404-probe limiter, whose job is to make alias scanning tedious, and wrong for the credential limiter, whose job is to make credential stuffing across a leaked list expensive — an attacker who can reach any replica gets N times the budget.
It is a backend for an existing Limiter rather than a replacement, because the fallback is the whole design: any Redis failure means the local bucket answers instead, so the limit degrades from "shared" to "per instance" rather than from "enforced" to "absent".
func NewShared ¶ added in v0.2.0
func NewShared(cfg SharedConfig) *Shared
NewShared returns a Redis backend, or nil if there is no client.
Nil is a valid backend and means "not shared", so an instance with the cache disabled keeps exactly the per-process limiter it had before.
type SharedConfig ¶ added in v0.2.0
type SharedConfig struct {
Name string
// Timeout bounds one round trip. Zero uses defaultSharedTimeout.
//
// It is short on purpose. This runs on the request path, including the
// login path, and a limiter that makes a request wait is worse than one
// that under-counts: the whole posture is that a limiter is abuse
// mitigation, not an availability dependency.
}
SharedConfig configures a Redis-backed limit.