Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,24 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7

# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Install the Go version this module declares, BEFORE codeql-action/init.
#
# Without this the autobuilder uses whatever Go the runner happens to
# ship, with GOTOOLCHAIN=local — so it cannot download a newer one, and
# the whole analysis fails the moment go.mod's `go` directive moves ahead
# of the image:
#
# go: go.mod requires go >= 1.27.0 (running go 1.26.6; GOTOOLCHAIN=local)
# Extraction failed for all discovered Go projects.
#
# That is a failure of the workflow rather than of the code, and it
# reports as "We were unable to automatically build your code", which
# points nowhere near the cause. Pinning from go.mod means this job builds
# what the project declares, whatever the runner image is that week.
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: ./go.mod

# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
Expand Down
52 changes: 50 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,10 +294,58 @@ That is the whole seam for "shared budget when the datastore is there,
in-process when it is not" — and the `Storage` stays the ordinary in-memory one
in both branches, because it only caches handles.

## Backends — one limit shared across processes

`Storage` holds limiters **in this process**. `Backend` holds the **count**,
wherever you want it: an in-process map, Valkey, Redis, DynamoDB, Postgres.

```go
type Backend interface {
Take(ctx context.Context, key string, limit Limit, cost int) (Decision, error)
}
```

That is the whole interface. Implement it and every process shares one budget.

```go
backend := ratelimiter.NewMemoryBackend() // or your own
limit := ratelimiter.Limit{Requests: 100, Period: time.Minute}

bl := ratelimiter.NewBucketLimiter(nil, time.Minute,
ratelimiter.NewInMemoryStorage[string, ratelimiter.Limiter](),
ratelimiter.WithLimiterFactoryForKey(
ratelimiter.NewBackendLimiterFunc(backend, limit,
ratelimiter.WithFallback(local)),
),
)
```

It is `Take`-shaped rather than `Get`/`Set`-shaped for a reason: a token bucket's
update is a read-modify-write, and split across a network that is a race in
which the limit silently becomes 2×. The decision has to run **where the state
lives**, so the interface is the *decision*, not the storage.

`BackendLimiter` adds the three things nobody should have to reimplement:

- a **local fallback**, because neither refusing nor allowing is an acceptable
answer to "the datastore is down" on its own;
- a **circuit breaker**, because falling back without one makes every request
during an outage pay a failed round trip first;
- a **degraded signal**, because a limiter silently enforcing N× the intended
limit is invisible from a request.

**[docs/BACKENDS.md](docs/BACKENDS.md)** is the full guide, including a ~25-line
Valkey implementation. Runnable example:

```bash
go run ./examples/backend
```

## Scope: single-process only

Token state lives in memory inside each `*rate.Limiter`, so this library
enforces limits **within one process**. Running N instances behind a load
Token state lives in memory inside each `*rate.Limiter`, so the **default**
limiter enforces limits **within one process**. Use a [`Backend`](#backends--one-limit-shared-across-processes)
to share one budget across instances. Running N instances behind a load
balancer yields an effective global limit of up to N × `limit`. Global,
cross-instance limiting requires a distributed algorithm (e.g. a Redis script)
and is out of scope. The `Storage` interface is for custom *in-process* stores,
Expand Down
108 changes: 108 additions & 0 deletions backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package ratelimiter

import (
"context"
"time"
)

// Limit is how much a rule allows, expressed the way an operator states it.
//
// Requests over Period, not a float rate: "300 per minute" is what a person
// says, what a form collects, and what a database row should hold. The rate is
// derived. Storing the float instead makes every caller guess at the window it
// came from.
type Limit struct {
// Requests is the budget over one Period.
Requests int

// Period is the window the budget applies to. One second, one minute, one
// hour — whatever the rule says.
Period time.Duration

// Burst is the capacity available in a single instant. Zero means
// Requests, which is the sensible default: a window-based backend has no
// separate notion of burst.
Burst int
}

// Rate returns the sustained refill rate in tokens per second.
func (l Limit) Rate() float64 {
if l.Period <= 0 {
return 0
}

return float64(l.Requests) / l.Period.Seconds()
}

// Capacity returns Burst, defaulting to Requests when Burst is unset.
func (l Limit) Capacity() int {
if l.Burst > 0 {
return l.Burst
}

return l.Requests
}

// Decision is what a [Backend] answers.
type Decision struct {
// Allowed reports whether the tokens were granted.
Allowed bool

// Remaining is the budget left in the current window after this call. It
// is best-effort: a backend that cannot compute it cheaply may report -1,
// and callers must treat a negative value as "unknown" rather than zero.
Remaining int

// RetryAfter is how long until the request would be granted. It is only
// meaningful when Allowed is false, and it may be an estimate — a
// window-based backend derives it from the window edge rather than from a
// continuous refill.
RetryAfter time.Duration
}

// Backend is the pluggable state store for a rate limiter: an in-process map,
// Valkey, Redis, DynamoDB, or anything else that can count atomically per key.
//
// # Why this is Take-shaped and not Get/Set-shaped
//
// It is tempting to define a backend as a small key-value interface — Get,
// Set, Incr — and let this package do the token arithmetic. That does not work
// for anything but an in-process store, and the reason is the whole design:
//
// read (tokens, timestamp) → refill by elapsed time → compare → write back
//
// is a read-modify-write. Split across a network it is a race: two replicas
// read the same state, both decide they may proceed, and both write. The limit
// silently becomes 2x. Making it safe needs either a transaction with a retry
// loop on a key that is contended by definition, or a server-side script — and
// neither is expressible through a Get/Set interface.
//
// So the decision has to run WHERE THE STATE LIVES, and the interface has to be
// the decision, not the storage. [Backend.Take] is that: one call, one answer,
// atomicity owned by the implementation because only the implementation can
// provide it.
//
// This is also exactly why [Storage] cannot be used for distributed limiting.
// Storage holds [Limiter] VALUES in this process; it is a container, and its
// Load/Store shape is the Get/Set shape described above. See
// docs/CUSTOM_STORAGE.md.
//
// # The contract
//
// Implementations MUST be safe for concurrent use, and Take MUST be atomic per
// key: two concurrent Takes for the same key must not both succeed on the
// strength of the same remaining token. Everything else — the algorithm, the
// key encoding, the expiry — is the implementation's business.
//
// An implementation SHOULD expire its own state. A rate limiter's key space is
// usually unbounded (one entry per client address), so a backend that never
// forgets is a leak.
type Backend interface {
// Take attempts to consume cost tokens for key under limit, and reports
// what happened. cost is normally 1.
//
// An error means the decision could not be made — not that it was denied.
// Callers decide what an unavailable backend implies; [BackendLimiter]
// falls back to a local limiter rather than guessing.
Take(ctx context.Context, key string, limit Limit, cost int) (Decision, error)
}
Loading