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 ¶
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.
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.
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) Configured ¶
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 ¶
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 ¶
Identified reports whether the request said who it was.
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 ¶
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 ¶
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 (Defaults) All ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 Email ¶
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 TokenFamily ¶
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 NewRecording ¶
NewRecording builds a limiter over a recorder, for Limiter.Take.
func (*Limiter) Allow ¶
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 ¶
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.
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.
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.
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.
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 (*Refusal) RetryAfter ¶
RetryAfter is how long until a slot frees.
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 ¶
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.