Rate limiting¶
Each rule keeps one counter per key in Valkey, shared by every edge, so a limit of 100 means 100 across the fleet. A request makes at most one Valkey round trip, whatever number of rules it matches. This page covers the rules you write; autopilot can add short-lived ones during an attack.
A rule¶
{
"id": "login",
"algorithm": "sliding_window",
"limit": 5,
"period": "1m",
"key": ["ip"],
"on_missing_key": "fallback_ip",
"action": "block",
"penalty": "5m",
"fail_mode": "open"
}
This is the rule's Then block in the UI; its expression (here http.request.uri.path eq "/login") is built above it. The form has no expires_at field: set it with Edit as JSON under Then, or in the rule's JSON through an import or PUT /api/draft (API).
| Field | Values |
|---|---|
algorithm |
fixed_window, sliding_window, token_bucket, leaky_bucket, distinct |
limit, period |
1 to 1e9 requests per 1s to 24h (whole seconds). For distinct, 1 to 1,000 different values |
burst |
token_bucket and leaky_bucket only, and required for them |
distinct_of |
distinct only, and required for it: what to count different values of, path, query:<name>, ip, asnum or ja4. See Distinct |
count_statuses |
Optional: count upstream responses with these statuses, such as "404" or "4xx", instead of requests. See Counting responses |
key |
1 to 5 parts: ip, jwt_claim:<name>, header:<name>, cookie:<name>, query:<name>, host, method, country, asnum, ja4, locale, or constant alone (one counter for everyone). IPv6 is keyed per /64. asnum and ja4 need a trusted viewer header |
on_missing_key |
skip the rule, fallback_ip, or reject the request |
action |
block, or log to count what would have been limited without blocking (shadow mode) |
penalty |
Optional, 1s to 24h: once over the limit, stay blocked this long |
fail_mode |
What to do when Valkey can't answer: open, closed or local |
expires_at |
Optional, an RFC 3339 time such as "2026-11-01T00:00:00Z": the rule stops running then. See Rules that expire |
Before you block: shadow mode¶
Start every rule in shadow mode, "action": "log": it counts the requests it would have limited and lets them through. A rule added in the UI starts that way. Stats shows its Over limit, log only count against real traffic, with the clients it would hit, and Needs attention on the dashboard lists log-only rules with requests over their limit. Switch it to block once the numbers look right, and publish that change through a canary so only part of the traffic sees it first. The rollback guard compares a changed rule with what it found while it only logged.
How a request is checked¶
Rate limiting runs after the local backstop (each edge's own per-IP flood limit), CORS and JWT, and before header rules and the proxy. Each rule below is checked on its own; then the request gets one answer.
A site-wide rule has the expression true, so it matches every request (Several rules on one request). "Limit ÷ fleet size" is the share of the limit each edge enforces on its own when Valkey can't answer a fail_mode: local rule (When Valkey fails).
flowchart TD
Start["Request reaches the<br/>rate-limit phase"] --> Pick["Pick rules: every site-wide rule,<br/>then up to 5 other matching rules,<br/>blocking rules before log rules"]
Pick --> HasKey{"Every key part<br/>present?"}
HasKey -->|"yes"| PenCache{"Key in this edge's<br/>penalty cache?"}
HasKey -->|"no, fallback_ip"| ByIP["Key on the client IP"]
HasKey -->|"no, skip"| Ignored["Rule ignored"]
HasKey -->|"no, reject"| Bad["400 bad_request"]
ByIP --> PenCache
PenCache -->|"yes"| Over["Over the limit"]
PenCache -->|"no"| Batch["One pipelined Valkey call<br/>for all remaining rules"]
Batch --> Answered{"Valkey<br/>answered?"}
Answered -->|"yes"| Algo{"Algorithm<br/>allows it?"}
Answered -->|"no"| Mode{"fail_mode"}
Mode -->|"open"| Pass["Rule passes"]
Mode -->|"closed"| Unavail["503, Retry-After: 1"]
Mode -->|"local"| Local["This edge's own counter<br/>at limit ÷ fleet size"]
Local --> Algo
Algo -->|"yes"| Pass
Algo -->|"no"| Over
Over --> Action{"action"}
Action -->|"log"| Shadow["Count would_limit,<br/>let it through"]
Action -->|"block"| Limited["429 with Retry-After"]
How the per-rule results combine:
- Any blocking rule over its limit: 429.
Retry-Afteris the longest wait among those rules. - Otherwise, any
fail_mode: closedrule that Valkey couldn't answer: 503. Alogrule never fails closed. - Otherwise the request continues to the header phase and the upstream.
Rules are independent counters. A request blocked by one rule still used quota in every other rule it matched, because checking all of them first would need a second round trip.
The algorithms¶
Every algorithm keeps its state in one Valkey key per subject and reads the time from Valkey, so all edges agree on it. The tables below were produced by running the algorithm code on each timeline. Times are from the start of a window.
Fixed window¶
Counts requests per calendar window of period. The count resets when the next window starts.
flowchart TD
Req["Request"] --> Same{"Stored window is<br/>the current one?"}
Same -->|"no"| Reset["Start a new window<br/>with a count of 0"]
Same -->|"yes"| Check{"count below limit?"}
Reset --> Check
Check -->|"yes"| Allow["count + 1, allow"]
Check -->|"no"| Deny["429, Retry-After =<br/>time to the window end"]
Scenario: limit: 5, period: 1m. A client sends 5 requests late in one window and 5 more right after it ends.
| Time | Result | Why |
|---|---|---|
| 0:59.0 to 0:59.4 | 5 allowed | Count goes 1 to 5 |
| 1:00.0 to 1:00.4 | 5 allowed | A new window started; the count went back to 0 |
| 1:00.5 | 429, Retry-After: 60 |
Count is 5 until 2:00 |
10 requests passed in 1.5 seconds. That edge burst is the price of the cheapest algorithm; use it for coarse quotas where it doesn't matter.
Sliding window¶
Smooths the edge burst. It keeps the count of the current window and the previous one, and estimates the last period as:
estimate = current count + previous count × the share of the previous window still inside the last period, rounded up
flowchart TD
Req["Request"] --> Roll{"Stored window?"}
Roll -->|"current one"| Est
Roll -->|"the one before"| Shift["previous = current<br/>current = 0"]
Roll -->|"older or none"| Zero["previous = 0<br/>current = 0"]
Shift --> Est["Compute the estimate"]
Zero --> Est
Est --> Check{"estimate below limit?"}
Check -->|"yes"| Allow["current + 1, allow"]
Check -->|"no"| Deny["429, Retry-After =<br/>time to the window end, or longer"]
Scenario: the same client and the same limit: 5, period: 1m.
| Time | Estimate before the request | Result |
|---|---|---|
| 0:59.0 to 0:59.4 | 0, 1, 2, 3, 4 | 5 allowed |
| 1:00.0 | 0 + 5 × 100% = 5 | 429 |
| 1:30.0 | 0 + 5 × 50% = 2.5, rounded up to 3 | Allowed |
| 1:30.1 | 1 + 3 = 4 | Allowed |
| 1:30.2 | 2 + 3 = 5 | 429 |
| 1:45.0 | 2 + 5 × 25% = 1.25, rounded up to 2, so 4 | Allowed |
| 2:00.0 | 0 + 3 × 100% = 3 | Allowed |
The edge burst is gone: at 1:00 the client gets nothing more until the old requests age out. Rounding up means the estimate never lets one extra request through. Good for login and signup limits.
Token bucket¶
A bucket holds up to burst tokens and starts full. It refills at limit tokens per period. Each request takes one token.
flowchart TD
Req["Request"] --> Refill["Add tokens for the time since<br/>the last request, up to burst"]
Refill --> Check{"At least<br/>one token?"}
Check -->|"yes"| Allow["Take one token, allow"]
Check -->|"no"| Deny["429, Retry-After =<br/>time to the next token"]
Scenario: limit: 10, period: 1m, burst: 5. One token every 6 seconds, at most 5 saved up.
| Time | Result | Why |
|---|---|---|
| 0:00, 5 requests | 5 allowed | The bucket starts full |
| 0:00, 6th request | 429, Retry-After: 6 |
Empty; the next token comes in 6s |
| 0:06, 2 requests | 1 allowed, then 429 | One token refilled |
| 0:30, 6 requests | 4 allowed, then 2 × 429 | 24s refilled 4 tokens |
A client can burst up to burst after a quiet spell, and averages limit per period over time. Good for API limits.
Leaky bucket¶
A bucket holds up to burst requests and starts empty. Each request adds one; the bucket drains at limit per period. A request that would overflow it is rejected, never queued. This is the meter form of the leaky bucket, the same as GCRA.
flowchart TD
Req["Request"] --> Drain["Drain the bucket for the time<br/>since the last request"]
Drain --> Check{"Level + 1<br/>≤ burst?"}
Check -->|"yes"| Allow["Level + 1, allow"]
Check -->|"no"| Deny["429, Retry-After =<br/>time until one request fits"]
Scenario: the same limit: 10, period: 1m, burst: 5. The results are the same as the token bucket, request for request: with equal settings the two admit the same traffic. Both are offered because operators think of limits in one model or the other. Choose leaky bucket when "at most one request every 6 seconds, with 5 of slack" is the way you describe protecting a fragile upstream.
Distinct¶
Counts how many different values of distinct_of a subject sends per period, not how many requests. Over the limit, every request of the subject is refused until the period ends, including values it sent before. It catches what a request rate can't: a client walking many paths slowly, an IP trying many account IDs, or an account used from many IPs.
flowchart TD
Req["Request"] --> Has{"Request has the<br/>distinct_of value?"}
Has -->|"no"| Skip["Rule not applied"]
Has -->|"yes"| Win{"Period since the<br/>subject's first value over?"}
Win -->|"yes"| Fresh["Start a new period<br/>with no values"]
Win -->|"no"| Mark
Fresh --> Mark["Mark the value's bit<br/>in a 1024-bit map"]
Mark --> Est{"Estimated different<br/>values ≤ limit?"}
Est -->|"yes"| Allow["Allow"]
Est -->|"no"| Deny["429, Retry-After =<br/>time to the period end"]
- The count is an estimate. Each value sets one bit of a 1024-bit map, and the count is estimated from the bits still clear (linear counting). Up to 1000 values it stays within 10% of the exact count (about 4% in tests). The map is 158 bytes per subject in Valkey.
- A period starts with the subject's first value, not on the clock.
distinct_ofvalues:pathis the normalized path;query:<name>the decoded first value of that argument;ipthe client IP (per /64 for IPv6);asnumandja4need their viewer source. A request without the value isn't counted by the rule and isn't refused by it.fail_mode: localis refused, since one map per subject per edge would outgrow the bounded local store. Useopenorclosed.- Count by something other than the value.
"key": ["ip"], "distinct_of": "path"limits paths per IP;"key": ["jwt_claim:sub"], "distinct_of": "ip"limits IPs per account. Changingdistinct_ofstarts every subject fresh.
Scenario: distinct, distinct_of: path, key: ["ip"], limit: 50, period: 5m. A scanner requests 60 different paths in 2 minutes, a few per second.
| Request | Result | Why |
|---|---|---|
| Paths 1 to 50 | Allowed | 50 or fewer different paths |
| Path 51 or so | 429, Retry-After up to the period end |
The estimate is over 50 |
| Path 1 again | 429 | Over the limit, every request is refused until the period ends |
A browser loading pages and their assets sends tens of paths, so start in log and set the limit above what Stats shows for real clients.
Choosing an algorithm¶
| Algorithm | Behaviour | Good for |
|---|---|---|
fixed_window |
Count per calendar window; allows up to 2 × limit across a window edge |
Cheap coarse quotas |
sliding_window |
Weighted previous window plus the current one | Login and signup limits |
token_bucket |
Bursts to burst, refills at limit per period |
API rate limits |
leaky_bucket |
Same admission as the token bucket, described as a draining bucket | Protecting a fragile upstream |
distinct |
Different values per period, estimated | Scanning, enumeration, accounts shared across IPs |
Changing limit or burst applies on the next request. Changing algorithm or period starts every subject from zero, because both are part of the Valkey key.
Counting responses¶
A rule with count_statuses counts the upstream's answers instead of requests: a client getting many 404s is probing for files, and one getting many 401s or 403s is guessing passwords.
{
"id": "404-sweep",
"algorithm": "fixed_window",
"limit": 30,
"period": "1m",
"key": ["ip"],
"on_missing_key": "fallback_ip",
"action": "log",
"penalty": "10m",
"fail_mode": "open",
"count_statuses": ["404"]
}
- Each edge counts in its own memory, against the full limit, with no Valkey call. Behind a load balancer spreading a client over N edges, it can get up to N ×
limitanswers before every edge blocks it. When the edge's local store is full, a new subject isn't counted, which is also fail-open; each counts inedge_ratelimit_local_untracked_total. - The answer that goes over the limit is already sent. The edge then blocks the key in its penalty cache until the penalty ends or, without one, until the period's count resets; later requests matching the rule get 429 without reaching the upstream. When the edge's penalty cache is full, the block isn't stored and an over-limit subject isn't refused: this fails open by design, and each drop counts in
edge_ratelimit_penalty_cache_dropped_total. - Statuses are 100 to 499, or the classes
1xxto4xx. 5xx is refused: it is usually the upstream's fault, and counting it would lock clients out after an outage. - Not with
distinct.fail_modehas no effect, since Valkey isn't used, and a value other thanopengets a warning. A count rule isn't a site-wide rule, nor theippartner for attacker-controlled keys. - Up to 5 count rules apply to a request, in their own cap beside the 5 other matching rules. They send no
RateLimitheaders. - Edge-generated answers aren't counted, only the upstream's.
Penalty¶
Without a penalty, a client over the limit gets through again as soon as the algorithm allows. With one, the first rejected request blocks the key for the whole penalty.
sequenceDiagram
participant Client
participant Edge
participant Valkey
Client->>Edge: 6th login in a minute
Edge->>Valkey: run the rule's script
Valkey-->>Edge: over the limit, blocked until now + 5m
Edge->>Edge: remember the block in its penalty cache
Edge-->>Client: 429, Retry-After: 300
Client->>Edge: retry 65 seconds later
Edge->>Edge: penalty cache hit, no Valkey call
Edge-->>Client: 429, Retry-After: 235
Scenario: the login rule above: sliding_window, limit: 5, period: 1m, penalty: 5m.
| Time | Result | Why |
|---|---|---|
| 0:00 to 0:04 | 5 allowed | Under the limit |
| 0:05 | 429, Retry-After: 300 |
6th request: the penalty starts and runs to 5:05 |
| 1:10 | 429, Retry-After: 235 |
Still blocked, although the window has moved on |
| 5:04 | 429, Retry-After: 1 |
Still blocked |
| 5:05 | Allowed | Penalty over; counting starts fresh |
- Repeat offenders cost almost nothing. Each edge keeps a local copy of active penalties (up to 100,000 keys) and answers from it without calling Valkey. An edge that hasn't seen the client yet asks Valkey once; Valkey answers "blocked" without counting, and that edge caches it too.
- Penalties apply only to
action: blockrules. Alogrule records that a penalty would have started and never blocks.
Rules that expire¶
A rule with expires_at stops running at that time, with no second publish. From then on every edge skips it: it takes no slot among the 5 rules a request runs, a client serving its penalty is let through, and /metrics counts the request as expired for the rule. Config tests judge the expiry at the time they run.
- Each edge uses its own clock. Edges whose clocks differ end the rule that much apart; run NTP on every host.
- It needs schema 5 edges. Admin holds the publish while a live edge reads an older schema, naming it.
- An expired rule stays valid. It is skipped, not rejected, so a bootstrap file exported while it ran still loads. A save or check of the draft warns about a rule of yours whose
expires_athas passed: the rule will not run.
Autopilot rules always expire, at most 24 hours after they are published.
When the key is missing¶
A key part can be absent: a missing header, cookie or query argument, no verified token for a jwt_claim, no country or ASN from the CDN, or no locale. on_missing_key decides what happens.
Scenario: an API rule keyed on ["jwt_claim:sub"], limit: 1000, period: 1h.
| Request | fallback_ip (default) |
skip |
reject |
|---|---|---|---|
Valid token, sub = user-42 |
user-42's counter | user-42's counter | user-42's counter |
No token on a route with auth: optional |
A counter for the client IP | Rule not applied | 400 bad_request |
fallback_ipis the safe default. A fallback key can never collide with a value a client chooses, so a header set to someone's IP can't land in that IP's bucket.skiplets a client bypass the rule by leaving the value out. Use it only when another rule covers those requests.- A
skipped rule takes no slot in the 5-rule cap.
Several rules on one request¶
Scenario: a site-wide rule per-ip (expression true, key ip, 300 per minute) and the login rule. A client sends its 6th login of the minute.
| Rule | Counter after the request | Result |
|---|---|---|
per-ip |
6 of 300 | Allowed |
login |
Over 5 | 429 |
The answer is 429, and per-ip still counted the request. Both rules were checked in one Valkey round trip.
Limits per request:
- Site-wide rules (expression
true) always run. At most 2 are allowed. - Up to 5 other matching rules run, blocking rules first, then
logrules. Requests that matched more are counted inedge_ratelimit_rules_capped_total. - Blocking rules go first because clients choose which rules match through what they send. Otherwise five matching
logrules could push a login limit out of evaluation.
When Valkey fails¶
Each Valkey call has a short deadline. After 5 consecutive failures on one Valkey node, the edge stops calling that node for 1 second, then lets one probe through. Only keys on the failed node are affected. Rate-limit calls are never retried: a call that timed out may already have counted, and a retry could count it twice.
Every rule then follows its fail_mode.
Scenario: Valkey is unreachable, 4 edges are running, and the login rule (limit: 5) receives traffic.
fail_mode |
Result | Use it when |
|---|---|---|
open (default) |
Every request passes this rule | Availability matters more than the limit |
closed |
503 with Retry-After: 1 |
The upstream must never see unlimited traffic |
local |
Each edge counts on its own with a limit of 5 ÷ 4, rounded down and at least 1. Across the fleet, about 4 logins per minute pass | You need a rough limit during an outage |
- The fleet size comes from admin, which counts the edges that reported stats in the last minute. Until admin has supplied it,
EXPECTED_REPLICASfrom deployment config is used. localstate is bounded at 100,000 keys per edge, split evenly between rules, and survives publishes during the outage.- Cluster deployments must set
cluster-require-full-coverage no. With the default, one failed primary fails every key, not just its own.
Rules that keep limits honest¶
- Attacker-controlled keys need a partner. A rule whose key includes
header,cookie,query,host,methodorja4is rejected unless the key also hasiporjwt_claim, or a site-wide rule keyed onipexists that counts requests in Valkey (notdistinct). Otherwise a client could rotate the value to get a fresh counter. - JWT claims count only after the token verified.
- Repeated values use the first one, so a client can't pick a bucket by reordering duplicate headers or arguments.
Response headers¶
Retry-Afteris on every 429, in whole seconds, rounded up.RateLimit-PolicyandRateLimit(IETF draft) are sent when a config rule setsratelimit_headers: true. There is one item per blocking rule, named by its ID, for exampleRateLimit-Policy: "login";q=5;w=60andRateLimit: "login";r=2;t=41.logrules are left out.
Local backstop¶
Before any shared limit, each edge applies a per-IP token bucket in memory (Settings → Backstop). It absorbs floods without touching Valkey and works even when Valkey is down. It is per edge, so the effective ceiling is about the number of edges × the setting. Set it well above any real client's rate.
When its table is full, new IPs pass the backstop untracked rather than share one bucket; the shared limits still apply to them.
Emergency switch¶
Settings → Kill switch → Disable rate limiting turns every rule into log. Save and publish it to stop all rate-limit blocking at once; the backstop still applies. Disable JWT checks turns JWT verification off entirely: every route behaves as auth: none, claims are empty and X-Edge-Auth-* headers are not set. Disable blocking by config rules turns every config rule's block setting into log.