Skip to content

The Store

wiki edited this page Sep 4, 2026 · 1 revision

The store

One bucket per (scope, client identity) pair, each holding a sliding-window counter. The store creates them on demand and evicts idle ones.

Default
DefaultCleanupInterval 5 minutes — how often eviction runs
DefaultMaxIdle 10 minutes — how long a bucket may be idle before removal
DefaultMaxBuckets 100,000 — the hard cap on distinct buckets

Why the cap exists

The store is keyed partly by client identity, which is derived from the request — so without a cap its size is bounded by the number of distinct clients an attacker can present, not by anything the application controls.

Idle eviction alone does not bound it. Eviction runs every five minutes and removes buckets idle for ten; a client rotating source addresses creates them faster than that, and each one survives ten minutes. The steady-state size is (creation rate × 10 minutes) — entirely the attacker's choice.

100,000 is high enough that a real deployment never reaches it and low enough to bound the memory: a bucket is a handful of integers, so the cap costs a few megabytes rather than a few gigabytes.

ratelimit.WithMaxBuckets(50_000) // 0 → the default; negative → no cap

Disabling the cap is only defensible when the key function has a provably bounded range — a fixed set of API keys, say — and even then the proof tends to expire.

What happens at the cap

New buckets are not created past it. The practical effect is that a client arriving after the cap is reached is not individually tracked.

If you are hitting the cap in production, the cause is almost always one of:

  • TrustedProxies is misconfigured in the other direction — trusting an untrusted hop, so a client-supplied X-Forwarded-For becomes the key. See Behind a Proxy.
  • The key function has unbounded range — keyed by something client-supplied and unhashed. See Client Identity.
  • Genuine scale, in which case raise it deliberately and watch the memory.

Lifetime

The store's cleanup goroutine is started in OnInitialize and stopped in OnStop, so it does not outlive the application.

Buckets are per process and not shared between replicas. Two replicas mean each client gets two budgets — the limit is per instance.

If you need a global limit across replicas, that requires a shared store (Redis and similar), which this extension does not implement. The usual workarounds:

  • Divide the intended limit by the replica count. Crude, and wrong during a rolling deploy when the count is temporarily higher.
  • Rate limit at the ingress, where there is one of it.
  • Accept per-instance limits, which for abuse prevention is usually fine — the attacker's cost still rises linearly.

Sliding window

Each bucket counts requests across a moving window rather than resetting at a fixed boundary.

A fixed window lets a client send its whole budget in the last second of one window and its whole budget again in the first second of the next — twice the intended rate across that boundary, which is exactly the moment a burst matters.

The headers

X-RateLimit-Limit: 600          the applicable limit
X-RateLimit-Remaining: 583      what is left in the current window
X-RateLimit-Reset: 1757000000   Unix seconds when the window rolls
Retry-After: 34                 on a 429 only

Set on every response, not only refusals — a client that can read its remaining quota can slow down; one that cannot discovers the limit by exceeding it.

For a browser client these must be exposed through CORS, which rextension-cors does by default. If you override WithExposedHeaders, include them explicitly — that option replaces the defaults.

Clone this wiki locally