Help us shape the future of Go! Take the 2026 Go Developer Survey

throttle

package
v0.6.7 Latest Latest
Warning

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

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package throttle rate-limits by counting what already happened.

There is no counter to keep in sync and no store to add. A limiter asks how many failures a key has recorded inside a window and decides from the answer, which makes it correct across replicas without coordination, correct after a restart, and self-healing: counters age out on their own, and a success clears the window rather than leaving somebody locked out by four typos.

The cost is a query per check. That is the right trade for the places it is used — login, password reset, refresh — where the alternative is a shared cache that has to be deployed, monitored, and reasoned about during an incident, in exchange for microseconds on a path that already runs argon2id.

Index

Constants

View Source
const (
	NameAPIKey  = "api.api_key"
	NameAccount = "api.account"
	NameTenant  = "api.tenant"
	NameIP      = "api.ip"
	// NameRoutePrefix is joined with the route pattern, so two routes with
	// limits are two budgets rather than one shared one.
	NameRoutePrefix = "api.route:"
)

The names the API limits count under. They are part of the counter's primary key, so they are constants rather than anything derived: changing one silently hands everybody a fresh budget.

View Source
const (
	KeyEmailAddress = "email_address"
	KeyIPAddress    = "ip_address"
	KeyAccount      = "account"
	KeyAPIKey       = "api_key"
	KeyTokenFamily  = "token_family"
	// KeyTenant is one customer's whole share, across every account and key
	// they have. It is the only kind with no column in the auth log, because it
	// belongs to the API limits rather than the auth ones: what it bounds is a
	// tenant starving the other tenants, which is not a question about
	// credentials.
	KeyTenant = "tenant"
)

The kinds of key the standard limits count against.

View Source
const (
	EventLoginFailed         = "LoginFailed"
	EventLoginSucceeded      = "LoginSucceeded"
	EventEmailCodeRequested  = "EmailCodeRequested"
	EventVerificationResent  = "VerificationResent"
	EventTokenRefreshed      = "TokenRefreshed"
	EventAPIKeyAuthFailed    = "ApiKeyAuthFailed"
	EventAPIKeyAuthSucceeded = "ApiKeyAuthSucceeded"
	EventInvitationPreviewed = "InvitationPreviewed"
)

The events the standard limits count. They match the auth log's event names, which is what lets the log double as the rate-limit substrate.

Variables

This section is empty.

Functions

This section is empty.

Types

type APILimits

type APILimits struct {
	// ByAPIKey, ByAccount and ByTenant apply to a caller who said who they
	// were. A machine integration is usually allowed the most, one person the
	// least, and the tenant ceiling is what stops one customer's batch job
	// starving the others.
	//
	// The first two are alternatives rather than a pair: a request made with a
	// key is counted as that key and not also as the account the key acts as.
	// Otherwise the tighter of the two — the account, on any sane ladder —
	// decides every key-authenticated request and the key's own number is
	// decoration. ByTenant applies either way, because it is a different
	// question: one customer's whole share.
	ByAPIKey  Limit
	ByAccount Limit
	ByTenant  Limit

	// ByIP applies only to a caller who did not say who they were.
	//
	// Only, and deliberately. An address is a poor name for a person — an
	// office behind one NAT is one address and a phone changes address between
	// requests — so once a request carries an identity, that identity is the
	// better key and the address is noise. It is also the weakest key there is,
	// which is worth saying out loud: addresses are cheap, and an attacker with
	// many of them is limited many times over rather than once.
	ByIP Limit

	// Routes are extra limits on particular route patterns, counted against the
	// caller's narrowest identity and on top of whatever the limits above
	// allow. The key is the pattern net/http matched, for example
	// "POST /api/v1/todos".
	Routes map[string]Limit

	// Exempt are patterns nothing applies to. A route whose whole job is to be
	// called constantly — a health check somebody put behind the API, a
	// long-lived stream — is better named here than given a number that has to
	// be guessed.
	Exempt map[string]bool
}

APILimits is what an API allows one caller, per window.

The zero value limits nothing, which is what a project that configured no throttle block gets.

func StandardAPILimits

func StandardAPILimits() APILimits

StandardAPILimits are the numbers a project starts with.

Deliberately loose. These are a backstop against a client in a retry loop and against one tenant crowding out the rest, not a quota — a limit tight enough to be interesting is one somebody has to choose for their own API, and a default that broke a working integration on upgrade would be worse than no default at all.

func (APILimits) Checks

func (l APILimits) Checks(c Caller, pattern string) []Check

Checks are the limits that apply to one request.

func (APILimits) Configured

func (l APILimits) Configured() bool

Configured reports whether any limit is set. A gate over nothing should not be built, rather than built and then skipped on every request.

type Caller

type Caller struct {
	APIKey  string
	Account string
	Tenant  string
	IP      string
}

Caller is who a request is from, as the API limits key on them.

Strings rather than the claims themselves, so that this package does not depend on tenancy — a limiter should not need to know what a tenant is to count against one. Empty means the request did not carry that identity.

func (Caller) Identified

func (c Caller) Identified() bool

Identified reports whether the request said who it was.

type Check

type Check struct {
	Limit Limit
	Key   Key
}

Check pairs a limit with the key to apply it to.

type Counter

type Counter interface {
	// Count returns how many of the limit's events happened for the key
	// strictly after since, and when the earliest of them was. The earliest is
	// the zero time when the count is zero.
	//
	// An occurrence of the limit's ClearedBy event resets the window: anything
	// at or before the most recent one does not count.
	//
	// The lower bound excludes since so that a window closes when it says it
	// will. An event the limiter promised would expire at 12:15 must not still
	// be counted at 12:15, or the Retry-After it handed out was a lie.
	Count(ctx context.Context, limit Limit, key Key, since time.Time) (n int, earliest time.Time, err error)
}

Counter reports what a key has already done.

Counting is not writing: a Counter never records anything. The caller writes its own log — which it was going to write anyway, for the audit trail — and the limiter reads it. That is what makes the two impossible to get out of step.

type Decision

type Decision struct {
	Allowed bool
	// Limit and Key are whichever check this decision came from: the one that
	// refused, or the one closest to refusing.
	Limit Limit
	Key   Key

	Used      int
	Remaining int

	// RetryAfter is how long until a slot frees. Zero when allowed.
	RetryAfter time.Duration
	ResetAt    time.Time
}

Decision is the outcome of a set of checks.

func (Decision) Err

func (d Decision) Err() error

Err returns the error to answer with, or nil when the request may proceed.

The message says how long to wait and nothing about why the limit exists. A login limiter that says "too many attempts for this email address" has just confirmed the address is worth attacking.

func (Decision) SetHeaders

func (d Decision) SetHeaders(h http.Header)

SetHeaders describes the limit to the client.

Retry-After is the one every client already understands. The RateLimit-* headers let a well-behaved one slow down before it is refused, which is the whole point of telling it anything.

type Defaults

type Defaults struct {
	// LoginByEmail locks one account after a handful of wrong codes.
	LoginByEmail Limit
	// LoginByIP throttles one source spraying many accounts.
	LoginByIP Limit

	// EmailCodeRequest bounds how often one address may be mailed a code.
	EmailCodeRequest Limit
	// EmailCodeByIP bounds how many codes one source may ask for, across every
	// address.
	//
	// The pair again, and the asymmetry is the same one — but this is the limit
	// that matters most, because with provisioning on, asking for a code writes
	// a person. Without it one script makes ten thousand identities.
	EmailCodeByIP Limit

	VerificationResend Limit

	// InvitationPreview bounds reading an invitation link, which is the one
	// unauthenticated endpoint keyed by a secret rather than by an address.
	// Nothing clears it: what it bounds is one source walking the token space,
	// and a preview that found something is as much evidence of that as one
	// that did not.
	InvitationPreview Limit

	// Refresh bounds one session's rotations. A client refreshing sixty times
	// a minute is looping, not working.
	Refresh Limit

	APIKeyFailures Limit
}

Defaults are the limits a project starts with.

The login pair is the reason this package exists in the shape it does. An email-only limit lets one attacker lock a victim out of their own account by failing on purpose; an IP-only limit lets a botnet spray one guess across every address it knows. So there are two, deliberately mismatched: the email threshold is tight, and the IP threshold is loose enough that an office behind one NAT does not trip it on a Monday morning.

func Standard

func Standard() Defaults

Standard returns the default limits.

func (Defaults) All

func (d Defaults) All() []Limit

All is every limit, in a slice.

For anything that has to reason about the set rather than pick one out of it — documenting them, or checking a retention window against them.

func (Defaults) LongestWindow

func (d Defaults) LongestWindow() (time.Duration, string)

LongestWindow is the furthest back any of these limits counts, and the name of the limit it belongs to.

It exists for one caller and the reason is worth stating where the answer is: the limits are counted from rig_auth_log, so anything that deletes from that table must not delete inside this window. A retention policy that did would clear a lockout by removing the failures it was adding up to — a limit that silently stops limiting, which is the failure nobody notices until it is being exploited. The name comes back with the duration so a refusal can say which limit it would have broken rather than only how short the window was.

type Gate

type Gate struct {
	// contains filtered or unexported fields
}

Gate is the API limiter as a server holds it.

It is here rather than written into each generated server because the two decisions it encodes — which limits apply, and what to do when the counter cannot answer — should have one implementation and one set of tests.

func NewGate

func NewGate(limiter *Limiter, limits APILimits, onError func(context.Context, error)) *Gate

NewGate builds a gate, or nil if nothing is configured.

Nil is usable: Gate.Check on a nil gate allows. That is what lets the generated server hold one field whether or not the project configured a throttle block.

func (*Gate) Check

func (g *Gate) Check(ctx context.Context, c Caller, pattern string, h http.Header) error

Check spends the request's slots, describes the limit in the headers, and returns the refusal to answer with — or nil to go ahead.

It fails open. If the counter cannot be reached the request is served, and the error goes to OnError rather than to the caller. This is the opposite of what the auth limits do, and the asymmetry is the point: a login limiter that fails open is a credential-stuffing window held open by whoever can make the database slow, while an API limiter that fails closed turns a database blip into a total outage — which is precisely the availability it was added to protect. The cost is stated rather than hidden: somebody who can degrade the database can also switch this off.

type Key

type Key struct {
	Kind  string
	Value string
}

Key is what a limit is counted against.

Kind matters as much as Value: an email limit and an IP limit are separate budgets, and counting them together would let one attacker exhaust both.

func APIKey

func APIKey(keyID string) Key

APIKey builds a key from an API key's public identifier.

func Account

func Account(id string) Key

Account builds a key from an account identifier.

func Email

func Email(lowercased string) Key

Email builds a key from an address.

The address is not normalized here: the caller has already lowercased it to look the account up, and normalizing in two places is how the limit ends up counting "Sam@example.com" separately from "sam@example.com".

func IP

func IP(addr string) Key

IP builds a key from a remote address.

func Tenant

func Tenant(id string) Key

Tenant builds a key from a tenant identifier.

func TokenFamily

func TokenFamily(rootID string) Key

TokenFamily builds a key from a session's root token.

type Limit

type Limit struct {
	// Name identifies the limit in errors and headers, for example
	// "login.email".
	Name string
	// Event is what is being counted. For the generated auth log it is an
	// event name such as "LoginFailed".
	Event string
	// ClearedBy is the event that resets the window, if any.
	//
	// It is what keeps a limit from punishing the wrong thing: somebody who
	// mistypes a password four times, gets it right, and mistypes once more is
	// not locked out, because the success wiped the earlier failures. Empty
	// means nothing clears the window — right for a limit whose event is not a
	// failure at all, such as bounding how often one session may refresh.
	ClearedBy string
	// Max is how many are allowed inside Window. The Max+1st is refused.
	Max int
	// Window is how far back the count reaches.
	Window time.Duration
}

Limit is how many failures of one kind a single subject may accumulate.

type Limiter

type Limiter struct {
	// contains filtered or unexported fields
}

Limiter applies limits.

func New

func New(counter Counter) *Limiter

New builds a limiter over a counter.

func NewRecording

func NewRecording(recorder Recorder) *Limiter

NewRecording builds a limiter over a recorder, for Limiter.Take.

func (*Limiter) Allow

func (l *Limiter) Allow(ctx context.Context, checks ...Check) (Decision, error)

Allow evaluates every check and reports the outcome.

Every check runs, even after one fails, because the headers should describe the tightest constraint the caller is actually under rather than whichever one happened to be listed first.

func (*Limiter) Take

func (l *Limiter) Take(ctx context.Context, checks ...Check) (Decision, error)

Take spends a slot against every check and reports the outcome.

It is Limiter.Allow for the limits that have to be written down to be counted. The difference that matters is what the number means: Allow's count is what happened *before* this request, so it refuses at Max; Take's count includes this request, so it refuses at Max+1. Both let exactly Max through.

Every check is spent, including after one of them has already refused. A refused request is still a request, and skipping the rest would let somebody sitting over one budget stay under all the others indefinitely — the count on the key they were not breaching would stop moving precisely because they were breaching another.

func (*Limiter) WithClock

func (l *Limiter) WithClock(fn func() time.Time) *Limiter

WithClock returns a copy that reads the time from fn. It exists for tests: a sliding window is exactly the kind of thing whose tests should not sleep.

type Local

type Local struct {
	// contains filtered or unexported fields
}

Local is a process-local tally in front of a Recorder.

It exists because the obvious implementation is a trap. A limiter that writes to Postgres on every API call adds a round trip to the hot path and — worse — puts every request from one busy caller on one row, so the counter becomes the bottleneck exactly when traffic is highest. That is a self-inflicted outage under the load the limiter was bought to survive.

So each process counts locally and reconciles periodically. Two facts make this sound rather than merely cheap:

Within a bucket the count only rises. Nothing decrements, so a replica's own tally plus the last global total it saw is a *lower bound* on the truth. Refusing on that lower bound can never refuse somebody who is actually under their limit — so a caller who is over it is refused from memory, and however fast the requests arrive they cost at most one write per Interval. The requests an attacker sends are precisely the ones this must not spend a round trip on.

The error is one-sided and bounded. What a replica cannot see is what the other replicas have counted since it last looked, so the limiter allows too much rather than too little, by at most one Interval of traffic per replica. For fair-use limiting that is the right trade. It is not the right trade for the auth limits, which is why those still count exact rows through Postgres.

func NewLocal

func NewLocal(next Recorder, cfg LocalConfig) *Local

NewLocal wraps a recorder with a process-local tally.

func (*Local) Incr

func (l *Local) Incr(ctx context.Context, limit Limit, key Key, now time.Time, delta int) (int, time.Time, error)

Incr implements Recorder.

type LocalConfig

type LocalConfig struct {
	// Interval is how long a replica may hold increments before publishing
	// them. It is the overshoot, stated as time: the limiter can miss up to one
	// Interval of traffic per replica.
	//
	// Publishing on a schedule is not an optimisation, it is what makes the
	// limit a limit. Ten replicas each quietly counting a fifth of the budget
	// are each under it and together at twice it.
	//
	// Zero means one second.
	Interval time.Duration

	// Threshold is the fraction of a limit above which a caller is reconciled
	// on every request regardless of Interval, so enforcement tightens as
	// somebody approaches their limit rather than after they pass it.
	//
	// Zero means 0.5.
	Threshold float64

	// MaxKeys bounds the map. Past it, new keys go straight to the recorder
	// rather than being remembered — slower, and the alternative is a process
	// whose memory is chosen by whoever is calling.
	//
	// Zero means 100_000.
	MaxKeys int
}

LocalConfig tunes how often a process tells the others what it has counted.

type Memory

type Memory struct {
	// contains filtered or unexported fields
}

Memory is an in-process Counter.

It is here for tests, and for a single-process deployment that has decided it does not need what the database gives. It is not the default for a reason: two replicas holding separate counters enforce a limit twice as loose as the one configured, and neither of them can tell.

func NewMemory

func NewMemory() *Memory

NewMemory builds an empty counter.

func (*Memory) Count

func (m *Memory) Count(_ context.Context, limit Limit, key Key, since time.Time) (int, time.Time, error)

Count implements Counter.

func (*Memory) Record

func (m *Memory) Record(event string, key Key, at time.Time)

Record notes that an event happened. It stands in for the row the caller would otherwise have written to its auth log.

type Postgres

type Postgres struct {
	// contains filtered or unexported fields
}

Postgres counts over a log table.

One indexed query answers the whole question — how many, when was the first, and has anything cleared the window since — which is why the limiter needs no state of its own and why two replicas cannot disagree.

func NewPostgres

func NewPostgres(db dbx.Conn, cfg PostgresConfig) *Postgres

NewPostgres builds a counter over a connection.

func (*Postgres) Count

func (p *Postgres) Count(ctx context.Context, limit Limit, key Key, since time.Time) (int, time.Time, error)

Count implements Counter.

type PostgresConfig

type PostgresConfig struct {
	// Table is the log table, for example "rig_auth_log".
	Table string
	// EventColumn holds the event name.
	EventColumn string
	// AtColumn holds when the event happened.
	AtColumn string

	// Match maps a [Key.Kind] onto the SQL that selects rows about it, with $1
	// bound to the key's value.
	//
	// It is an expression rather than a column name so that a comparison can
	// say what it means: an email address is matched case-insensitively and an
	// address needs a cast, and neither is expressible as a bare column.
	Match map[string]string
}

PostgresConfig says where the events are.

The defaults match the auth log `rig setup-project` writes. It is configurable anyway because the interesting part is the mapping from a key kind to a column, and an application that logs something rig did not think of should be able to rate-limit on it.

func DefaultPostgresConfig

func DefaultPostgresConfig() PostgresConfig

DefaultPostgresConfig is the shape `rig setup-project` scaffolds.

type Recorder

type Recorder interface {
	// Incr adds delta events for the key at now and returns the total inside
	// the limit's window, with the instant that window's oldest part expires.
	//
	// delta is a count rather than an implied one so that a caller which held
	// events back can flush them together. Zero is legal and asks the question
	// without spending anything.
	Incr(ctx context.Context, limit Limit, key Key, now time.Time, delta int) (total int, resetAt time.Time, err error)
}

Recorder spends a slot and reports what the key has spent, in one round trip.

It is the other half of Counter, for the limits whose event nobody would want in an audit trail. A login failure is worth a row on its own merits and the limiter reads it for free; an ordinary API call is not, and there are a great many more of them. So here the request *is* the event: it has to be recorded to be counted, and the recording and the counting are one statement because two would race.

The returned total is the whole window, not the delta — a caller that batches locally still needs the global answer to decide with.

type Refusal

type Refusal struct {
	// contains filtered or unexported fields
}

Refusal is a rate-limit error that still knows the decision behind it.

The wait has to survive the trip out to the HTTP layer, which is several packages away from the limiter and has no way to ask again. Carrying it on the error is what lets the response set Retry-After without every handler in between knowing about rate limits.

func RefusalOf

func RefusalOf(err error) (*Refusal, bool)

RefusalOf finds the rate-limit refusal in an error chain, if there is one.

func (*Refusal) Decision

func (r *Refusal) Decision() Decision

Decision is the whole answer, for a caller that wants the headers.

func (*Refusal) Error

func (r *Refusal) Error() string

Error implements error.

func (*Refusal) RetryAfter

func (r *Refusal) RetryAfter() time.Duration

RetryAfter is how long until a slot frees.

func (*Refusal) Unwrap

func (r *Refusal) Unwrap() error

Unwrap exposes the typed error, so rigerr.CodeOf and rigerr.Is see a rate-limit error rather than an unrecognized one.

type Tally

type Tally struct {
	// contains filtered or unexported fields
}

Tally counts into rig_throttle.

It is the Recorder half of this package, for the limits whose event is not worth a row of its own — which is every ordinary API call. Where Postgres reads a trail somebody was keeping anyway, this keeps a number, and the difference in cost is the reason the two exist separately.

The window slides by weighting: a limit's window is the bucket size, and the count is the current bucket plus however much of the previous one is still inside the window. That is an approximation of a true sliding window and it is a deliberate one — the exact answer is a row per event, which is the shape this table exists to avoid. It is much closer than plain fixed buckets, which let twice the limit through either side of a boundary.

func NewTally

func NewTally(db dbx.Conn, cfg TallyConfig) *Tally

NewTally builds a recorder over a connection.

func (*Tally) Incr

func (t *Tally) Incr(ctx context.Context, limit Limit, key Key, now time.Time, delta int) (int, time.Time, error)

Incr implements Recorder.

func (*Tally) Sweep

func (t *Tally) Sweep(ctx context.Context, olderThan time.Duration, now time.Time) (int64, error)

Sweep deletes tallies whose window has passed, and returns how many.

Unlike the auth log there is no retention window to respect here and nothing to get wrong by pruning too eagerly: a bucket older than the longest limit cannot be counted by anything, so deleting it cannot free a caller who is still over. That is most of what this table buys over counting the audit log, where the same sweep would quietly clear a lockout.

type TallyConfig

type TallyConfig struct {
	// Table is the counter table. Empty means rig_throttle.
	Table string
}

TallyConfig says where the counters are.

func DefaultTallyConfig

func DefaultTallyConfig() TallyConfig

DefaultTallyConfig is the table runtime's foundation creates.

Jump to

Keyboard shortcuts

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