ratelimit

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 10 Imported by: 0

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

Constants

This section is empty.

Variables

This section is empty.

Functions

func Key

func Key(addr netip.Addr) string

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

func RetryAfterSeconds(d time.Duration) int

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

func New(perMinute int, opts Options) *Limiter

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

func (l *Limiter) Allow(addr netip.Addr) (bool, time.Duration)

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

func (l *Limiter) AllowKey(key string) (bool, time.Duration)

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) Charge

func (l *Limiter) Charge(addr netip.Addr)

Charge consumes a token if one is available, ignoring the answer.

func (*Limiter) Check

func (l *Limiter) Check(addr netip.Addr) (bool, time.Duration)

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

func (l *Limiter) Fallbacks() int64

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) Len

func (l *Limiter) Len() int

Len reports tracked keys. For tests and the metrics collector.

func (*Limiter) Overflows

func (l *Limiter) Overflows() int64

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

func (l *Limiter) RefundKey(key string)

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

	// Shared makes this limit apply across replicas. Nil keeps it per process,
	// 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.

func (*Shared) Fallbacks added in v0.2.0

func (s *Shared) Fallbacks() int64

Fallbacks reports how many decisions were made locally because Redis did not answer. Nonzero means the limit is no longer shared.

type SharedConfig added in v0.2.0

type SharedConfig struct {
	Client *goredis.Client
	// Name distinguishes this limit's keys, e.g. "login".
	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.
	Timeout time.Duration
	Logger  *slog.Logger
}

SharedConfig configures a Redis-backed limit.

Jump to

Keyboard shortcuts

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