L42 · Rate limiting and resource consumption
Module 6: API Security · ~20 min · Lesson 42 of 71
🎯 What you'll learn
- What "resource consumption" covers besides request counts
- How fixed windows, sliding windows, token buckets and leaky buckets work
- How to choose the key you limit by (IP, consumer, account) and price expensive calls higher
- How to configure Kong's
rate-limitingplugin, and why itspolicychanges the real limit
🔥 Why this matters
At 2 a.m. a partner's batch job retries in a tight loop: 3,000 requests a second to
GET /v1/positions, each a MongoDB aggregation. The pods hit the HPA maximum, MongoDB's CPU pins at
100%, and every other client times out. Nobody attacked you; the API simply had no ceiling. That's
API4:2023 Unrestricted Resource Consumption (L39).
1. What you're actually protecting
Every request spends something: CPU, memory, database time, bandwidth, and sometimes money (an SMS, a credit-bureau lookup). Counting requests is only one of the limits you need.
client
│
▼
┌─────────────────────────────────────────────────────┐
│ Kong requests per minute per consumer │
│ max body size, connection timeouts │
├─────────────────────────────────────────────────────┤
│ service max page size (?limit=100000 → 100) │
│ per-account limits on login, OTP, export │
│ cost-based budget for expensive endpoints │
├─────────────────────────────────────────────────────┤
│ MongoDB maxTimeMS on queries, indexes, pool size │
├─────────────────────────────────────────────────────┤
│ vendors daily spend cap on SMS / bureau calls │
└─────────────────────────────────────────────────────┘
The gateway counts requests. Only the service knows that one ?limit=100000 call costs as much as
a thousand normal ones, so you need limits at every layer (defense in depth, L04).
2. Four ways to count
Every limiter asks whether a key has used up its allowance. They differ in how they remember.
Fixed window. Keep one counter per key per clock window ("minute 14:03") and reject above the limit. It's cheap, but it allows a boundary burst: a whole allowance at the end of one window and another at the start of the next.
limit: 10 per minute, fixed windows
window 14:03 window 14:04
|............................████|████............................|
10 req ▲ 10 req
14:04:00
fixed window: 20 requests in ~2 s, all allowed
sliding window: at 14:04:01 the last 60 s already hold
~10 requests, so the second burst is refused
Sliding window. Count the last 60 seconds from now. A sliding window log stores every timestamp (exact, but memory grows with traffic). A sliding window counter keeps two counts, this window's and the previous one's weighted by how much of it still overlaps: a good estimate.
Token bucket. Each key has a bucket that holds up to B tokens and refills at r tokens per second. A request takes a token, or is refused if none are left. Short bursts up to B are fine, and the long-run rate can't exceed r.
refill: r = 10 per minute (one every 6 s)
│
▼
┌─────────────┐
│ ● ● ● ● ● │ holds at most B = 10 tokens
│ ● ● ● ● ● │ (the largest burst allowed)
└──────┬──────┘
│ a request takes 1 token
│ (an export might take 20)
▼
token left? ──yes──▶ forward to the service
│
no ────────────▶ 429 Too Many Requests + Retry-After
That makes cost-based limits natural: an export simply costs more tokens.
Leaky bucket. Requests join a queue that drains at a fixed rate; when the queue is full, new requests are dropped. The output is perfectly smooth, at the cost of added latency. (Some tools use the name for a meter equivalent to a token bucket.)
| Algorithm | Memory per key | Bursts | Example |
|---|---|---|---|
| Fixed window | 1 counter | Up to 2× limit at a boundary | Kong rate-limiting (open source) |
| Sliding window log | Every timestamp | Exact | Redis sorted set, small scale |
| Sliding window counter | 2 counters | Smoothed estimate | Kong Rate Limiting Advanced (window_type: sliding, Enterprise) |
| Token bucket | Tokens + last time | Up to B, then rate r | AWS API Gateway throttling (rate + burst) |
| Leaky bucket (queue) | A queue | None: constant output | NGINX limit_req |
3. Who are you limiting?
The key decides who shares a counter, and it matters as much as the number.
- By IP is the fallback for unauthenticated routes. It's weak: an office shares one NAT address, while a botnet gets a fresh allowance per address.
- By consumer or credential (the authenticated client) is the right default behind a gateway: a runaway job hurts only its owner.
- By the account being acted on protects login, OTP and password reset: 5,000 IPs guessing one customer's code are still 5,000 attempts on one account (L31 made the same point).
- A global ceiling per route protects the backend no matter how many keys share it.
Set limits per route: a 6-digit OTP has a million values, so five attempts per code makes guessing
hopeless, while a limit sized for GET /v1/quotes doesn't.
4. Kong's rate-limiting plugin
The open-source rate-limiting plugin counts fixed windows (second through year). In decK
format, on a route:
plugins:
- name: rate-limiting
route: transactions-read
config:
minute: 600 # per consumer
hour: 20000 # both limits apply
limit_by: consumer # needs an auth plugin on the route
policy: redis # local | cluster | redis
redis: # Kong 3.6+; older: redis_host, ...
host: redis.kong.svc.cluster.local
port: 6379
timeout: 2000 # ms
fault_tolerant: true # Redis down: proxy unlimited
hide_client_headers: false
With the Kong Ingress Controller, the same config goes in a KongPlugin resource. If Kong can't
identify a consumer, the plugin falls back to limiting by IP.
The policy decides where counters live, and therefore what "600 per minute" really means:
load balancer
┌─────────────┼─────────────┐
▼ ▼ ▼
policy: local kong-1: 600 kong-2: 600 kong-3: 600
→ up to 1,800/min get through (and 2,400
after the HPA adds a fourth pod)
policy: redis kong-1 kong-2 kong-3
└──INCR───────┼──────INCR───┘
▼
┌──────────────┐
│ Redis: 1 │ → 600/min, plus one
│ counter/key │ network hop each
└──────────────┘
| Policy | Counters live in | Trade-off |
|---|---|---|
local |
Each Kong node's memory | Fastest, no dependency. The real limit grows with the node count and resets on restart. |
cluster |
Kong's database (PostgreSQL) | Shared, but a database write per request. Not available in DB-less mode (common on Kubernetes) or on hybrid-mode data planes. |
redis |
An external Redis | Shared and accurate. Redis must be highly available, and fault_tolerant decides whether an outage fails open or closed (L04). |
local is fine for a coarse "stop the flood" limit, if you remember it's per pod. For limits you
promise partners, or that protect money or credentials, use redis. Set policy explicitly rather
than relying on your version's default.
🧪 Worked example: the 2 a.m. partner job, with limits
With the plugin above, the partner's 601st request in a minute gets:
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 600
RateLimit-Remaining: 0
RateLimit-Reset: 23 ◀ seconds until the window resets
X-RateLimit-Limit-Minute: 600
X-RateLimit-Remaining-Minute: 0
Retry-After: 23 ◀ a well-behaved client waits
{"message":"API rate limit exceeded"}
Kong answers without touching the service. The service still caps what one allowed request costs:
// application.yml: spring.data.web.pageable.max-page-size: 100
// (Spring Data's default cap is 2000)
@GetMapping("/v1/positions")
Page<PositionView> list(@AuthenticationPrincipal Jwt jwt,
Pageable page) { // size ≤ 100
Query q = Query.query(where("accountId").is(jwt.getSubject()))
.with(page)
.maxTime(Duration.ofSeconds(2)); // maxTimeMS
return positions.find(q); // runs in Mongo
}
One call can now spend at most two seconds of MongoDB time and return 100 rows. (positions.find
stands in for a MongoTemplate query plus a count.) Cap body size too: Kong's
request-size-limiting plugin, or express.json(), which refuses bodies over 100 KB by default.
🛠 Try it (optional, ~5 min)
Compare three algorithms on a boundary burst. Save this as limits.py:
# limits.py: one burst, three limiters. Limit: 10 per 60 s.
L, W = 10, 60.0
burst = [59 + i / 20 for i in range(10)] + \
[60 + i / 20 for i in range(10)] # just before/after 1:00
def fixed(ts):
n = {}
for t in ts:
w = t // W
if n.get(w, 0) < L:
n[w] = n.get(w, 0) + 1
return sum(n.values())
def sliding(ts):
n = {}
for t in ts:
w = t // W
est = n.get(w - 1, 0) * (1 - t % W / W) + n.get(w, 0)
if est < L:
n[w] = n.get(w, 0) + 1
return sum(n.values())
def bucket(ts, tokens=L, last=0.0):
ok = 0
for t in ts:
tokens = min(L, tokens + (t - last) * L / W) # refill
last = t
if tokens >= 1:
tokens, ok = tokens - 1, ok + 1
return ok
for f in (fixed, sliding, bucket):
print(f"{f.__name__:8} accepted {f(burst):2} of 20")
Real output:
$ python3 limits.py
fixed accepted 20 of 20
sliding accepted 11 of 20
bucket accepted 10 of 20
The sliding window's 11 is its estimate being slightly generous after the boundary. Change the
second burst to start at 120 and predict the results before you run it.
🧠 Key takeaways
- API4 is about cost, not just request counts: cap size, time, page length and spend as well.
- Fixed windows allow 2× bursts at the boundary; sliding windows and token buckets smooth them.
- Limit by consumer or by target account, not only by IP, and set limits per route.
- Kong's
localpolicy multiplies the limit by the pod count;redisshares one counter, andfault_tolerantdecides what a Redis outage does. - The gateway counts requests; only the service can bound what one request costs.
📖 Words to know
| Term | Meaning |
|---|---|
| Rate limit | A cap on how many requests a key may make in a period of time |
| Fixed window | Counting requests per clock interval, reset at the start of each one |
| Sliding window | Counting requests in the trailing interval ending now |
| Token bucket | A bucket of up to B tokens refilled at rate r; each request spends tokens |
| Leaky bucket | A queue drained at a constant rate; overflow is dropped |
| 429 Too Many Requests | The HTTP status for a rate-limited request, often with Retry-After |
| Cost-based limit | Charging expensive operations more of the allowance than cheap ones |
⏭ Next time
L43 steps back to the gateway as a whole: what Kong does well as a security control (authentication, mTLS, logging, limits) and what it can't do for you.
Rate limiting across many pods: atomic counters in Redis
When you write your own limiter (a per-account OTP limit in the service, say), you face the problem
Kong's redis policy solves: many pods, one counter. The naive version is wrong in a way that's
easy to miss in testing.
The race
Two pods read the counter, both see 9 under a limit of 10, and both write 10. Two requests got through, and the counter says one did.
pod A Redis pod B
│ GET otp:C-2001 ─────────▶│ │
│◀──────────────── 9 ──────│◀──── GET ─────────│
│ │───── 9 ──────────▶│
│ 9 < 10: allow │ 9 < 10: allow│
│ SET otp:C-2001 10 ──────▶│ │
│ │◀── SET ... 10 ────│
▼ ▼ ▼
counter = 10, but 11 requests were allowed
Under real load (or an attacker sending requests in parallel on purpose), "read, decide, write" leaks. It's the same class of bug as a double-spend: a race condition between a check and an update.
Atomic options
INCRfirst, decide after.INCRis atomic, so each request gets a unique count back. If the count is over the limit, refuse. No two requests can both see 10.- Set the expiry safely.
INCRthenEXPIREis two commands; a crash between them leaves a counter with no expiry, which locks the key out forever. Use a script (below), or Redis 7'sEXPIRE key seconds NX, which only sets an expiry if none exists. - Lua scripts run atomically. For a token bucket you need to read tokens, compute the refill and write back as one step, so put it in a script.
A fixed-window limiter as one atomic script:
-- KEYS[1] = "rl:otp:C-2001:" .. window number
-- ARGV[1] = limit, ARGV[2] = window length in seconds
local n = redis.call("INCR", KEYS[1])
if n == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[2])
end
if n > tonumber(ARGV[1]) then
return 0 -- refuse
end
return 1 -- allow
Worked example: a token bucket in Redis
-- KEYS[1] = "tb:consumer:partner-42"
-- ARGV: capacity, refill per second, cost of this request
local cap, rate, cost =
tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local t = redis.call("TIME") -- Redis's clock
local now = t[1] + t[2] / 1e6
local s = redis.call("HMGET", KEYS[1], "tokens", "ts")
local tokens = tonumber(s[1]) or cap
local ts = tonumber(s[2]) or now
tokens = math.min(cap, tokens + (now - ts) * rate)
local ok = tokens >= cost
if ok then tokens = tokens - cost end
redis.call("HSET", KEYS[1], "tokens", tokens, "ts", now)
redis.call("EXPIRE", KEYS[1], math.ceil(cap / rate) * 2)
return ok and 1 or 0
Three design points: it uses Redis's TIME rather than each pod's clock, so clock skew between
nodes doesn't mint tokens (calling TIME before writes in a script needs Redis 5 or later, where
scripts replicate their effects). cost makes it a cost-based limiter. And the expiry cleans up
idle keys, so memory doesn't grow with every consumer you've ever seen.
Then decide the failure mode deliberately. If Redis is unreachable, a login or OTP limiter should probably fail closed, while a coarse traffic limit might fail open (L04's deep dive). On EKS, run Redis with replication and keep the limiter's timeout short, so a slow Redis doesn't become your outage.
🧠 Extra takeaways
- "Read, check, write" across pods is a race; attackers trigger it on purpose with parallel requests.
- Use
INCR-then-check or a Lua script so the check and the update are one atomic step. - Never leave a counter without an expiry; use a script or
EXPIRE ... NX(Redis 7+). - Use the server's clock, add a cost per request, and choose fail-open or fail-closed per limiter.
Being a good client: 429s, Retry-After, backoff and jitter
Rate limiting has two sides. Your services and scheduled jobs (Airflow DAGs in the examples) also call APIs: internal ones behind the gateway, and vendors outside. A client that handles 429s badly turns a short blip into an outage, and can lock you out of a partner's API.
Retry storms
A service slows down. Every client times out and retries immediately, so the load doubles, then triples as retries retry. If all clients use the same fixed delay, they all come back at the same moment, in waves. The service never recovers until the clients stop.
load
│ ▲ first failure
│ ███ │ ███ ███ ███ fixed delay: retries
│ ███ │ ███ ███ ███ arrive in waves
│ ███████████ ███ ███
└───────────────────────────────────▶ time
│
│ ███ │ full jitter: the same
│ ███████▄▄▄▄▃▃▃▃▃▂▂▂▂▂▂▂▂▂▂ retries, spread out
└───────────────────────────────────▶ time
The rules
- Honor
Retry-After. RFC 9110 allows a number of seconds or an HTTP date. Kong's 429 sends seconds. Never retry sooner than it says. - Otherwise, exponential backoff with jitter. Double a delay cap each attempt and sleep a random time up to it ("full jitter", as described in AWS's Architecture Blog). Randomness is what breaks up the waves.
- Cap attempts and total time. Give up after a few tries and surface the failure.
- Retry only what's safe. GETs are idempotent. A POST that moves money is only safe to retry
with an idempotency key the server deduplicates on (an IETF draft standardizes an
Idempotency-Keyheader; several payment APIs already use one). - Retry only retryable errors: 429, 503, timeouts. A 400 or 403 will fail the same way again.
Worked example: an Airflow task that calls a rate-limited API
import random, time
import requests
def get_with_backoff(url, headers, max_tries=5, base=1, cap=60):
for attempt in range(max_tries):
r = requests.get(url, headers=headers, timeout=10)
if r.status_code not in (429, 503):
r.raise_for_status()
return r.json()
ra = r.headers.get("Retry-After", "")
if ra.isdigit():
delay = int(ra) # the server knows best
else:
delay = random.uniform(0, min(cap, base * 2 ** attempt))
time.sleep(delay)
raise RuntimeError(f"gave up on {url} after {max_tries} tries")
At the DAG level, two Airflow features do the rest. Task arguments retries, retry_delay,
retry_exponential_backoff=True and max_retry_delay space out whole-task retries. And a pool
(for example pool="partner_api" with 4 slots) caps how many tasks call that API at once, however
many DAGs run in parallel. A pool is a concurrency limit you apply to yourself, so a backfill of 300
dates doesn't become 300 simultaneous callers.
Watch the response headers too. If RateLimit-Remaining is near zero, slow down before you get the
429. (RateLimit-* headers come from an IETF draft that's still changing, so names and formats
vary between providers.)
🧠 Extra takeaways
- Immediate or fixed-delay retries synchronize clients into waves; jitter spreads them out.
- Honor
Retry-After, cap attempts, and retry only idempotent requests or ones with an idempotency key. - Retry 429, 503 and timeouts; don't retry 400 or 403.
- Airflow pools limit your own concurrency against a partner's API; task retries add backoff between attempts.
The quiz for this lesson and the Tests section need JavaScript.