ratelimit

package
v0.6.0 Latest Latest
Warning

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

Go to latest
Published: Oct 19, 2026 License: MIT Imports: 6 Imported by: 0

Documentation

Overview

Package ratelimit defines the port authit's service packages use to throttle expensive or guessable operations, plus an in-memory implementation of it.

What this is not

This is not a replacement for rate limiting at your HTTP layer, and it cannot be. A service method sees only the arguments it is given: some have an IP address, some have only an email, and none of them see headers, routes, or the shape of the request. Per-route, per-IP limiting belongs in middleware, where that information exists.

What this port covers is the subset a host *cannot* implement from outside, because the thing being guessed is looked up inside authit: the device flow's user codes, and the login paths where refusing early avoids running a deliberately expensive password KDF for an attacker.

Use both.

Index

Constants

View Source
const DefaultMaxKeys = 100_000

DefaultMaxKeys bounds how many distinct keys a Memory tracks.

Variables

View Source
var ErrRateLimited = errors.New("authit/ratelimit: rate limit exceeded")

ErrRateLimited is the sentinel every rejection wraps, so a caller can classify one with errors.Is without knowing the implementation.

Functions

func AllowEach

func AllowEach(ctx context.Context, l Limiter, keys ...string) error

AllowEach consults l for each key in turn and returns the first refusal, or nil if every key was allowed.

Two rules, both of which were learned the hard way and neither of which is obvious from Limiter:

A key ending in ":" is skipped. Callers build keys by joining a namespace to a value -- "login:ip:" + ipAddress -- and a value that is absent leaves the separator dangling. Consulting that key would put every caller with no IP address into one shared bucket, so the first few would spend the budget of all the rest. Skipping is the safe reading: the dimension is unknown for this caller, so it cannot be metered.

Every key is consulted even after one refuses. Returning early would leave the later dimensions uncharged, and an attacker who can make the first key refuse -- by exhausting a bucket they share with nobody -- would then ride the others for free.

A limiter's own failure (a Redis timeout, not a refusal) is returned immediately and unwrapped, so a caller can tell "you are going too fast" from "the control is broken" and fail closed on the second.

func RetryAfter

func RetryAfter(err error) (time.Duration, bool)

RetryAfter reports how long a caller should wait before retrying, if err is a refusal that carries a hint. It returns false for any other error, including a refusal from an implementation that does not provide one.

Types

type Error

type Error struct {
	Key        string
	RetryAfter time.Duration
}

Error is a refusal, carrying a hint for how long to wait. Hosts can surface RetryAfter in a 429 response.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap makes errors.Is(err, ErrRateLimited) true for every *Error.

type Limiter

type Limiter interface {
	Allow(ctx context.Context, key string) error
}

Limiter decides whether an operation identified by key may proceed now, consuming budget when it says yes. Returning a non-nil error refuses it.

Implementations should return an *Error (or something wrapping ErrRateLimited) for a refusal, and reserve other error values for their own failures — a Redis timeout, say. The distinction matters at the call site: the service packages treat a refusal as a denial to report to the caller, and anything else as a fault to propagate.

Allow must be safe for concurrent use.

type Memory

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

Memory is an in-process token-bucket Limiter, suitable for a single instance and for tests.

It is deliberately not suitable for a horizontally scaled deployment: each process keeps its own buckets, so N instances behind a load balancer permit N times the configured rate. Implement Limiter over Redis (or whatever you already run) when that matters — it is one method.

func NewMemory

func NewMemory(cfg MemoryConfig) *Memory

NewMemory returns an in-memory limiter.

func (*Memory) Allow

func (m *Memory) Allow(_ context.Context, key string) error

Allow consumes one unit of key's budget, refusing with an *Error when there is none left.

If the table is at MaxKeys and no bucket can be evicted -- meaning every tracked key is currently being limited -- a previously unseen key is refused rather than admitted untracked. That is the fail-closed choice: admitting it would be a bypass (flood the table with junk keys, then proceed unmetered), and growing past the cap would make the limiter a memory-exhaustion vector, which is exactly what the cap is for. The cost is that a genuine new caller is turned away while the flood lasts. At the default MaxKeys that state means 100,000 distinct keys are simultaneously over their limit, so the refusal is not the biggest problem you have.

func (*Memory) Len

func (m *Memory) Len() int

Len reports how many keys are currently tracked.

func (*Memory) Reset

func (m *Memory) Reset()

Reset discards all state, restoring full budget to every key. It exists for tests and for an operator "unblock everyone" action.

type MemoryConfig

type MemoryConfig struct {
	// Burst is how many operations may happen back to back before the
	// limit bites. Must be at least 1; defaults to 5.
	Burst int
	// Interval is how long it takes to earn one unit of budget back.
	// Defaults to 1 minute, i.e. a sustained rate of one per minute with
	// bursts of Burst.
	Interval time.Duration
	// MaxKeys is a hard cap on tracked keys, so a caller varying the key on
	// every request cannot grow this map without bound. Defaults to
	// DefaultMaxKeys.
	//
	// Reaching it means every tracked key is currently under restriction,
	// which is itself a sign of a broad attack. See Allow for what happens
	// then; the short version is that it fails closed.
	MaxKeys int
}

MemoryConfig configures a Memory limiter.

type Noop

type Noop struct{}

Noop allows everything. It is what a Config with a nil RateLimiter gets, so leaving the field unset disables the control rather than panicking — the same opt-in shape as audit.Logger.

func (Noop) Allow

func (Noop) Allow(context.Context, string) error

Jump to

Keyboard shortcuts

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