Split View: 레이트 리밋 알고리즘 고르기 — 고정 윈도우, 슬라이딩, 토큰 버킷의 실제 차이
레이트 리밋 알고리즘 고르기 — 고정 윈도우, 슬라이딩, 토큰 버킷의 실제 차이
들어가며 — 분당 100회로 막았는데 200회가 들어왔다
레이트 리밋을 붙였습니다. 키당 분당 100회. 그런데 모니터링을 보니 어떤 클라이언트가 200밀리초 사이에 200개의 요청을 통과시켰습니다. 로그를 뒤져도 코드에는 문제가 없습니다.
버그가 아닙니다. 고정 윈도우 카운터의 정의된 동작입니다. 그리고 이 사실을 모르고 배포한 리밋은 보호하려던 대상을 정확히 보호하지 못합니다.
레이트 리밋은 한 줄짜리 미들웨어처럼 보이지만 실제로는 네 가지 결정의 묶음입니다. 어떤 알고리즘을 쓸 것인가, 무엇을 키로 삼을 것인가, 분산 환경에서 카운터를 어디에 둘 것인가, 막힌 클라이언트에게 무엇을 알려 줄 것인가. 하나씩 봅니다.
네 가지 알고리즘과 트레이드오프
| 알고리즘 | 키당 저장 공간 | 정확도 | 버스트 허용 | 분산 구현 난이도 | 어울리는 곳 |
|---|---|---|---|---|---|
| 고정 윈도우 | 카운터 1개 | 낮음. 경계에서 최대 2배 통과 | 사실상 무제한 | 쉬움. INCR 하나 | 대략적인 남용 차단, 내부 도구 |
| 슬라이딩 로그 | 요청 수만큼 타임스탬프 | 정확 | 없음 | 어려움. 정렬 집합 관리 | 한도가 작고 정확성이 중요한 경우 |
| 슬라이딩 윈도우 카운터 | 카운터 2개 | 높음. 근사 오차 작음 | 없음 | 중간. Lua 스크립트 | 범용 API 리밋의 기본값 |
| 토큰 버킷 | 토큰 수와 마지막 갱신 시각 | 정확 (버스트 정의 포함) | 통 크기만큼 명시적 | 중간. Lua 스크립트 | 공개 API, 클라이언트 친화적 제한 |
표만 봐도 선택이 좁혀집니다. 슬라이딩 로그는 정확하지만 비싸고, 고정 윈도우는 싸지만 부정확합니다. 실무에서 남는 것은 슬라이딩 윈도우 카운터와 토큰 버킷 둘입니다.
고정 윈도우의 경계 문제를 숫자로
한도가 분당 100회이고 윈도우가 매분 0초에 리셋된다고 하겠습니다. 클라이언트가 이렇게 보냅니다.
10:00:59.900 요청 100개 → 10:00 윈도우 카운터 0 → 100. 전부 통과
10:01:00.100 요청 100개 → 10:01 윈도우 카운터 0 → 100. 전부 통과
200밀리초 동안 200개가 통과했습니다. 임의의 연속된 60초 구간을 기준으로 보면 한도의 두 배입니다. 이것이 고정 윈도우의 상한입니다. 어떤 60초 구간에서도 최대 2배가 통과할 수 있습니다.
한도가 클수록 절대 피해가 커집니다. 분당 6000회 한도라면 순간적으로 12000개가 들어옵니다. 이 정도면 보호하려던 백엔드가 그대로 무너집니다.
윈도우를 짧게 쪼개면 완화됩니다. 분당 100회 대신 초당 2회로 바꾸면 경계 초과폭이 절대량으로 작아집니다. 다만 정상적인 버스트도 같이 잘려서 사용자 경험이 나빠집니다.
슬라이딩 윈도우 카운터는 이전 윈도우의 카운트를 경과 비율만큼 가중해서 더합니다.
현재 시각이 10:01:15 (윈도우의 25% 경과)
이전 윈도우(10:00) 카운트 = 100
현재 윈도우(10:01) 카운트 = 20
추정치 = 100 * (1 - 0.25) + 20 = 95
95 < 100 이므로 통과. 5개만 더 받으면 차단
이전 윈도우의 요청이 균등하게 분포했다고 가정하는 근사입니다. 가정이 어긋나도 오차는 작습니다. 클라우드플레어가 자사 트래픽으로 이 방식을 평가한 결과를 공개한 바 있는데, 잘못 허용되거나 잘못 차단된 요청 비율은 0.003퍼센트 수준이었습니다. 카운터 두 개로 슬라이딩 로그에 근접한 정확도를 얻는 셈입니다.
슬라이딩 로그의 비용도 짚어 둘 만합니다. 레디스 정렬 집합에 요청마다 타임스탬프를 넣으면 원소 하나가 실제로는 수십 바이트에서 100바이트 가까이 차지합니다. 한도 100, 활성 키 100만 개면 항목 1억 개, 수 GB입니다. 카운터 두 개로 끝나는 방식과 비교하면 세 자릿수 차이입니다.
토큰 버킷 — 버스트를 허용하는 것이 API에 맞는 이유
토큰 버킷은 두 개의 숫자로 정의됩니다. 통 크기와 초당 충전 속도입니다. 요청이 오면 토큰 하나를 꺼내고, 없으면 거절합니다. 토큰은 시간에 비례해 채워지되 통 크기를 넘지 않습니다.
충전은 타이머로 하지 않습니다. 요청이 올 때 경과 시간만큼 계산해서 채우면 됩니다. 그래서 저장할 것이 토큰 수와 마지막 갱신 시각 두 개뿐입니다.
function consume(state, now, capacity, refillPerSec, cost = 1) {
const elapsed = (now - state.updatedAt) / 1000
const tokens = Math.min(capacity, state.tokens + elapsed * refillPerSec)
if (tokens < cost) {
const wait = (cost - tokens) / refillPerSec
return { allowed: false, retryAfter: Math.ceil(wait), state: { tokens, updatedAt: now } }
}
return { allowed: true, state: { tokens: tokens - cost, updatedAt: now } }
}
여기서 중요한 것은 버스트를 명시적으로 정의한다는 점입니다. 통 크기 20에 충전 10개/초라면, 조용히 있다가 한 번에 20개를 쏘는 것은 허용하되 장기 평균은 초당 10개로 묶입니다.
이 성질이 API에 맞는 이유는 실제 클라이언트가 균등하게 요청하지 않기 때문입니다. 페이지 하나를 열면 병렬 요청 여덟 개가 동시에 나갑니다. 사용자가 목록을 열고 필터를 걸면 짧은 구간에 요청이 몰립니다. 엄격한 슬라이딩 윈도우로 초당 10개를 강제하면 이런 정상적인 화면 로드가 잘립니다. 사용자는 서비스가 고장 났다고 느끼는데, 서버는 한가합니다.
레이트 리밋의 목적은 요청 간격을 고르게 만드는 것이 아니라 장기 부하를 상한선 아래로 유지하는 것입니다. 토큰 버킷은 그 목적을 정확히 표현합니다. 그래서 공개 API 대부분이 이 방식을 쓰고, 문서에 통 크기와 충전 속도를 그대로 노출합니다.
비용 가중도 자연스럽게 붙습니다. 목록 조회는 토큰 1개, 리포트 생성은 토큰 20개처럼 엔드포인트마다 비용을 다르게 매기면, 호출 횟수가 아니라 실제 자원 소비를 제한하게 됩니다. 깃허브 API가 쿼리 복잡도에 따라 점수를 다르게 매기는 것이 같은 발상입니다.
분산 환경 구현 — 원자적 연산과 로컬 리밋의 오차
노드가 여럿이면 카운터를 공유해야 하고, 공유하는 순간 경쟁 조건이 생깁니다. 읽고 판단하고 쓰는 세 단계 사이에 다른 노드가 끼어들면 한도를 넘깁니다.
레디스에서 흔히 쓰는 조합에도 함정이 있습니다.
# 위험 — INCR 직후 프로세스가 죽으면 TTL이 없는 키가 영원히 남습니다
INCR rl:user_8812:1784056020
EXPIRE rl:user_8812:1784056020 60
키가 만료되지 않으면 그 사용자는 카운터가 한도에 도달한 채로 영구히 막힙니다. 원자적으로 처리해야 합니다.
-- 슬라이딩 윈도우 카운터. KEYS[1]=이전 윈도우, KEYS[2]=현재 윈도우
-- ARGV: 1=한도, 2=윈도우 길이(초), 3=현재 윈도우 경과 비율
local prev = tonumber(redis.call('GET', KEYS[1]) or '0')
local curr = tonumber(redis.call('GET', KEYS[2]) or '0')
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local elapsed = tonumber(ARGV[3])
local estimated = prev * (1 - elapsed) + curr
if estimated >= limit then
return {0, limit, 0}
end
curr = redis.call('INCR', KEYS[2])
if curr == 1 then
redis.call('EXPIRE', KEYS[2], window * 2)
end
return {1, limit, math.floor(limit - estimated - 1)}
Lua 스크립트는 레디스에서 단일 스레드로 원자 실행되므로 판단과 증가 사이에 끼어들 여지가 없습니다. 토큰 버킷도 같은 방식으로 옮길 수 있고, 최근 레디스에는 이 목적의 확장 모듈도 있습니다.
그렇다면 노드마다 로컬 카운터를 두는 방식은 얼마나 틀릴까요. 노드가 N개이고 로드밸런서가 완벽하게 균등 분배한다면, 각 노드에 한도를 N분의 1로 나눠 주면 총합이 맞습니다. 문제는 두 가지입니다.
분배가 균등하지 않습니다. 커넥션 유지 방식이나 해시 기반 라우팅 때문에 한 클라이언트의 요청이 특정 노드로 쏠리면, 그 클라이언트는 전체 한도의 N분의 1만 쓰고 차단됩니다. 실제 한도가 광고한 값보다 훨씬 작아집니다.
노드 수가 변합니다. 오토스케일링으로 노드가 늘면 각 노드의 몫을 다시 계산해야 하는데, 그러지 않으면 총 허용량이 노드 수에 비례해 늘어납니다. 노드 10개에 각각 분당 100을 걸어 두면 실제 한도는 분당 1000입니다.
그래서 실용적인 배치는 두 겹입니다. 노드 로컬에는 자기 자신을 보호하는 느슨한 상한을 두고, 정확한 사용자별 한도는 공유 저장소에서 판단합니다. 저장소 왕복이 부담되면 노드가 중앙 버킷에서 토큰을 묶음으로 임차해 로컬에서 소비하는 방식도 씁니다. 정확도를 조금 내주고 왕복 횟수를 크게 줄이는 절충입니다.
레디스가 죽었을 때의 동작도 미리 정해야 합니다. 전부 차단하면 리밋 저장소 장애가 전면 장애가 되고, 전부 통과시키면 그 순간 보호가 사라집니다. 보통은 통과시키되 노드 로컬 상한은 유지하고, 알람을 올립니다.
무엇을 키로 삼을 것인가
IP를 키로 쓰는 것은 기본값처럼 보이지만 여러 방향으로 깨집니다.
한 IP 뒤에 사용자가 수천 명 있을 수 있습니다. 회사 네트워크, 학교, 통신사의 대규모 NAT가 그렇습니다. IP 기준으로 조이면 정상 사용자 집단이 통째로 막힙니다.
반대로 공격자는 IP를 쉽게 바꿉니다. 클라우드에서 IP를 회전시키는 비용은 거의 없고, IPv6는 호스트 하나에 접두사 하나가 통째로 할당되는 경우가 많아 사실상 무한합니다. IPv6를 키로 쓸 때는 전체 주소가 아니라 상위 접두사 단위로 묶어야 최소한의 의미가 생깁니다.
프록시 뒤에서 클라이언트 IP를 읽는 방식도 자주 틀립니다. X-Forwarded-For는 클라이언트가 임의로 채워 보낼 수 있는 값이라, 목록의 맨 앞을 그대로 믿으면 공격자가 매 요청마다 다른 IP를 주장해서 리밋을 무력화합니다. 신뢰하는 프록시 수를 세어 뒤에서부터 해당 위치의 값을 취해야 합니다.
우선순위는 이렇게 잡습니다. 인증된 요청이면 사용자 식별자나 API 키가 1순위입니다. 요금제와 결부되므로 테넌트 단위 쿼터도 함께 겁니다. 인증 이전 단계, 즉 로그인이나 가입 같은 엔드포인트에서는 IP 외에 쓸 것이 마땅치 않으므로 IP를 쓰되 한도를 넉넉히 잡고, 대신 계정 식별자 기준의 별도 한도를 겹칩니다. 로그인 시도는 IP당 한도와 계정당 한도를 동시에 걸어야 크리덴셜 스터핑과 단일 계정 무차별 대입을 함께 막을 수 있습니다.
키를 여러 겹으로 겹칠 때는 가장 좁은 것부터 검사하고, 어떤 한도에 걸렸는지를 응답에 남겨야 디버깅이 가능합니다.
429와 클라이언트의 재시도
한도를 초과했을 때는 429를 씁니다. 503은 서버 과부하, 403은 권한 없음이므로 의미가 다릅니다. 클라이언트가 상황을 판단할 수 있도록 정보를 실어 보냅니다.
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 12
RateLimit-Limit: 600
RateLimit-Remaining: 0
RateLimit-Reset: 12
Cache-Control: no-store
Retry-After는 초 또는 HTTP 날짜를 받으며, 클라이언트는 자신의 백오프 계산보다 이 값을 우선해야 합니다. RateLimit 계열 헤더는 IETF에서 표준화가 진행 중인 필드로, 남은 횟수와 초기화까지 남은 시간을 알려 줍니다. 기존 서비스들이 쓰던 X-RateLimit 접두사 버전이 여전히 널리 쓰이므로 둘 다 내보내는 것도 방법입니다.
429 응답 자체는 값싸야 합니다. 차단된 요청에서 DB를 조회하거나 무거운 직렬화를 하면, 공격 트래픽이 그대로 부하가 됩니다. 리밋 판정은 가능한 한 앞단에서 끝내고 응답 본문은 짧게 유지합니다. 그리고 429에는 캐시 헤더를 붙이지 않습니다. 중간 캐시가 429를 저장했다가 정상 상태의 클라이언트에게 돌려주는 사고가 실제로 발생합니다.
이제 클라이언트 쪽입니다. 차단된 클라이언트가 고정 간격으로 재시도하면 무슨 일이 벌어지는지가 핵심입니다.
서버가 잠시 흔들려서 1000개의 클라이언트가 동시에 실패했다고 합시다. 모두 1초 후에 재시도하면, 1초 뒤에 1000개가 동시에 도착합니다. 서버가 다시 흔들리고, 다시 2초 후 재시도하면 2초 뒤에 또 1000개가 동시에 도착합니다. 지수 백오프를 넣어도 간격이 결정적이면 재시도가 동기화된 파도로 남습니다. 이것이 썬더링 허드입니다.
해법은 무작위성입니다.
// 하지 마세요 — 모든 클라이언트가 같은 순간에 깨어납니다
const delay = Math.min(cap, base * 2 ** attempt)
// 풀 지터 — 0과 상한 사이에서 무작위로 뽑습니다
const delay = Math.random() * Math.min(cap, base * 2 ** attempt)
AWS가 공개한 백오프 비교 실험에서 풀 지터는 총 재시도 횟수와 전체 완료 시간을 함께 줄였습니다. 상한의 절반을 고정으로 깔고 나머지만 무작위로 하는 방식보다도 나았습니다. 직관과 어긋나 보이지만, 대기 시간을 넓게 퍼뜨리는 것이 충돌을 줄이는 데 더 효과적이기 때문입니다.
재시도 구현에서 함께 지켜야 할 것들이 있습니다. 서버가 Retry-After를 줬으면 그 값을 따릅니다. 최대 시도 횟수와 전체 마감 시간을 두어 무한 재시도를 막습니다. 429와 5xx는 재시도하되 4xx의 나머지는 재시도하지 않습니다. 그리고 멱등하지 않은 요청을 재시도할 때는 멱등성 키를 함께 보내서 중복 실행을 막아야 합니다. 타임아웃으로 실패한 결제 요청을 그냥 재시도하면 두 번 결제될 수 있습니다.
마치며 — 알고리즘보다 먼저 정할 것
레이트 리밋을 붙일 때 가장 먼저 정할 것은 알고리즘이 아니라 무엇을 보호하려는가입니다. 백엔드 용량을 지키려는 것이라면 비용 가중 토큰 버킷이 맞고, 공정한 분배가 목적이라면 테넌트 단위 쿼터가 맞고, 무차별 대입을 막으려는 것이라면 계정과 IP를 겹친 좁은 한도가 맞습니다. 목적이 다르면 키도 한도도 달라집니다.
고정 윈도우는 언제나 한도의 두 배를 통과시킬 수 있고, 카운터 두 개짜리 슬라이딩 윈도우로 바꾸는 비용은 거의 없습니다. 그다음에는 클라이언트에게 상태를 알려 주는 일이 남습니다. 남은 횟수와 재시도 시각을 정확히 내려 주면 잘 만든 클라이언트는 스스로 물러섭니다. 알려 주지 않으면 모두가 같은 순간에 다시 문을 두드립니다.
Choosing a Rate Limiting Algorithm — What Really Separates Fixed Window, Sliding, and Token Bucket
Introduction — the limit was 100 per minute and 200 came in
You added a rate limit. 100 per minute per key. Then monitoring shows a client that pushed 200 requests through in the space of 200 milliseconds. You dig through the logs and there is nothing wrong with the code.
It is not a bug. It is the defined behaviour of the fixed window counter. And a limit deployed without knowing this fails to protect exactly the thing it was meant to protect.
Rate limiting looks like a one-line middleware, but it is really a bundle of four decisions. Which algorithm to use, what to key on, where to keep the counter in a distributed setup, and what to tell a client that has been blocked. Let us take them one at a time.
Four algorithms and their tradeoffs
| Algorithm | Storage per key | Accuracy | Burst allowance | Distributed difficulty | Where it fits |
|---|---|---|---|---|---|
| Fixed window | 1 counter | Low. Up to 2x passes at the boundary | Effectively unlimited | Easy. A single INCR | Rough abuse blocking, internal tools |
| Sliding log | One timestamp per request | Exact | None | Hard. Managing a sorted set | Small limits where accuracy matters |
| Sliding window counter | 2 counters | High. Small approximation error | None | Medium. A Lua script | The default for general API limits |
| Token bucket | Token count and last refill time | Exact (with a defined burst) | Explicit, up to bucket size | Medium. A Lua script | Public APIs, client-friendly limits |
The table alone narrows the choice. The sliding log is exact but expensive, and the fixed window is cheap but inaccurate. What is left in practice is the sliding window counter and the token bucket.
The fixed window boundary problem, in numbers
Say the limit is 100 per minute and the window resets at second zero of every minute. The client sends this.
10:00:59.900 100 requests → 10:00 window counter 0 → 100. all pass
10:01:00.100 100 requests → 10:01 window counter 0 → 100. all pass
200 requests passed in 200 milliseconds. Measured over any arbitrary continuous 60-second span, that is twice the limit. This is the fixed window ceiling. In any 60-second span, up to 2x can pass.
The larger the limit, the larger the absolute damage. With a limit of 6000 per minute, 12000 arrive in an instant. That is enough to take down the very backend you were protecting.
Chopping the window shorter mitigates it. Switching from 100 per minute to 2 per second makes the boundary overshoot smaller in absolute terms. But it also clips legitimate bursts, which degrades the user experience.
The sliding window counter adds the previous window count weighted by how much of the current window has elapsed.
current time is 10:01:15 (25% into the window)
previous window (10:00) count = 100
current window (10:01) count = 20
estimate = 100 * (1 - 0.25) + 20 = 95
95 < 100 so it passes. 5 more requests and it blocks
It is an approximation that assumes the previous window's requests were evenly distributed. Even when the assumption is off, the error is small. Cloudflare published an evaluation of this approach against its own traffic, and the share of requests wrongly allowed or wrongly blocked came out around 0.003 percent. In effect you get close to sliding-log accuracy with two counters.
The cost of the sliding log is worth pinning down too. If you put a timestamp per request into a Redis sorted set, a single element actually takes tens of bytes up to close to 100 bytes. With a limit of 100 and one million active keys, that is 100 million entries and several gigabytes. Compared with an approach that needs two counters, that is a three-order-of-magnitude difference.
Token bucket — why allowing bursts is the right fit for an API
A token bucket is defined by two numbers: bucket size and refill rate per second. When a request arrives you take one token out, and if there is none you reject it. Tokens refill in proportion to time but never exceed the bucket size.
Refilling is not done with a timer. You compute it from the elapsed time when a request arrives. That is why there are only two things to store, the token count and the last refill time.
function consume(state, now, capacity, refillPerSec, cost = 1) {
const elapsed = (now - state.updatedAt) / 1000
const tokens = Math.min(capacity, state.tokens + elapsed * refillPerSec)
if (tokens < cost) {
const wait = (cost - tokens) / refillPerSec
return { allowed: false, retryAfter: Math.ceil(wait), state: { tokens, updatedAt: now } }
}
return { allowed: true, state: { tokens: tokens - cost, updatedAt: now } }
}
What matters here is that the burst is defined explicitly. With a bucket size of 20 and a refill of 10 per second, staying quiet and then firing 20 at once is allowed, while the long-run average stays pinned at 10 per second.
The reason that property fits APIs is that real clients do not send requests evenly. Opening one page fires eight parallel requests at once. A user opening a list and applying a filter bunches requests into a short interval. Enforcing 10 per second with a strict sliding window clips these perfectly normal screen loads. The user feels the service is broken while the server is idle.
The purpose of a rate limit is not to even out the spacing between requests but to keep long-run load under a ceiling. The token bucket expresses that purpose precisely. That is why most public APIs use it and publish the bucket size and refill rate directly in their documentation.
Cost weighting attaches naturally too. Charge 1 token for a list query and 20 tokens for report generation, and you end up limiting actual resource consumption rather than call counts. The GitHub API scoring queries differently by complexity is the same idea.
Distributed implementation — atomic operations and the error in local limits
With several nodes you have to share the counter, and the moment you share it you get a race condition. If another node slips in between the read, the decision, and the write, the limit gets exceeded.
Even the combination commonly used in Redis has a trap.
# dangerous — if the process dies right after INCR, a key with no TTL stays forever
INCR rl:user_8812:1784056020
EXPIRE rl:user_8812:1784056020 60
If the key never expires, that user stays blocked permanently with the counter pinned at the limit. It has to be handled atomically.
-- sliding window counter. KEYS[1]=previous window, KEYS[2]=current window
-- ARGV: 1=limit, 2=window length (seconds), 3=elapsed fraction of current window
local prev = tonumber(redis.call('GET', KEYS[1]) or '0')
local curr = tonumber(redis.call('GET', KEYS[2]) or '0')
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local elapsed = tonumber(ARGV[3])
local estimated = prev * (1 - elapsed) + curr
if estimated >= limit then
return {0, limit, 0}
end
curr = redis.call('INCR', KEYS[2])
if curr == 1 then
redis.call('EXPIRE', KEYS[2], window * 2)
end
return {1, limit, math.floor(limit - estimated - 1)}
A Lua script executes atomically on a single thread in Redis, so there is no room for anything to slip in between the decision and the increment. A token bucket can be moved over the same way, and recent Redis versions even ship an extension module for this purpose.
So how wrong is the approach of keeping a local counter on each node? With N nodes and a load balancer that distributes perfectly evenly, giving each node one Nth of the limit makes the total add up. There are two problems.
Distribution is not even. When connection reuse or hash-based routing pushes one client's requests onto a particular node, that client gets only one Nth of the total limit before being blocked. The real limit becomes far smaller than the advertised one.
The node count changes. When autoscaling adds nodes, each node's share has to be recomputed, and if it is not, the total allowance grows in proportion to the node count. Set 100 per minute on each of 10 nodes and the real limit is 1000 per minute.
So the practical arrangement is two layers. Keep a loose ceiling node-local to protect the node itself, and make the precise per-user decision in the shared store. If the store round trip is a burden, there is also the approach where a node leases tokens from a central bucket in batches and consumes them locally. That is a compromise that gives up a little accuracy to cut round trips sharply.
What happens when Redis goes down also has to be decided in advance. Blocking everything turns a limiter store outage into a total outage; passing everything removes protection at that exact moment. The usual choice is to pass, keep the node-local ceiling in force, and raise an alert.
What to key on
Keying on IP looks like the default, but it breaks in several directions.
There can be thousands of users behind one IP. Corporate networks, schools, and carrier-grade NAT are like that. Squeeze on IP and an entire group of legitimate users gets blocked wholesale.
Conversely, an attacker changes IP easily. Rotating IPs in the cloud costs almost nothing, and with IPv6 a single host is often assigned a whole prefix, which makes it effectively unlimited. When keying on IPv6, you have to group by the upper prefix rather than the full address for it to mean anything at all.
Reading the client IP behind a proxy also goes wrong often. X-Forwarded-For is a value the client can fill in arbitrarily, so trusting the front of the list verbatim lets an attacker claim a different IP on every request and neutralise the limit. You have to count your trusted proxies and take the value at that position counting from the end.
Set priorities like this. For an authenticated request, the user identifier or API key comes first. Since it ties to the pricing plan, apply a tenant-level quota alongside it. At pre-authentication stages, that is endpoints like login or signup, there is nothing much to use other than IP, so use IP but set the limit generously, and layer a separate account-identifier limit on top. Login attempts need a per-IP limit and a per-account limit at the same time in order to stop credential stuffing and single-account brute force together.
When you layer several keys, check the narrowest one first, and record which limit was hit in the response so debugging is possible.
429 and client retries
When the limit is exceeded, use 429. A 503 means server overload and a 403 means not authorised, so they mean different things. Send information along so the client can work out its situation.
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 12
RateLimit-Limit: 600
RateLimit-Remaining: 0
RateLimit-Reset: 12
Cache-Control: no-store
Retry-After takes seconds or an HTTP date, and the client should prefer this value over its own backoff calculation. The RateLimit family of headers is a set of fields being standardised at the IETF, reporting the remaining count and the time left until reset. The X-RateLimit prefixed versions that existing services have used are still widespread, so emitting both is a reasonable option.
The 429 response itself should be cheap. If a blocked request queries the database or does heavy serialization, attack traffic turns straight into load. Finish the limit decision as far forward as possible and keep the response body short. And do not attach cache headers to a 429. Incidents where an intermediate cache stored a 429 and served it back to a healthy client do happen.
Now the client side. What matters is what happens when a blocked client retries at a fixed interval.
Suppose the server wobbles briefly and 1000 clients fail at once. If they all retry after 1 second, 1000 requests arrive simultaneously one second later. The server wobbles again, they all retry after 2 seconds, and 1000 more arrive simultaneously two seconds later. Even with exponential backoff, deterministic intervals leave retries as a synchronized wave. This is the thundering herd.
The solution is randomness.
// do not do this — every client wakes up at the same instant
const delay = Math.min(cap, base * 2 ** attempt)
// full jitter — draw at random between 0 and the ceiling
const delay = Math.random() * Math.min(cap, base * 2 ** attempt)
In the backoff comparison AWS published, full jitter reduced both the total number of retries and the overall completion time. It even beat the approach that keeps half the ceiling fixed and randomizes only the rest. It looks counterintuitive, but spreading the wait times widely is simply more effective at reducing collisions.
There are other things to observe in a retry implementation. If the server gave a Retry-After, follow that value. Set a maximum attempt count and an overall deadline so retries do not go on forever. Retry on 429 and 5xx, but not on the rest of the 4xx family. And when you retry a non-idempotent request, send an idempotency key so duplicate execution is prevented. Blindly retrying a payment request that failed on a timeout can charge the customer twice.
Closing — what to decide before the algorithm
The first thing to decide when adding a rate limit is not the algorithm but what you are trying to protect. If it is backend capacity, a cost-weighted token bucket is right; if the goal is fair distribution, a tenant-level quota is right; if it is stopping brute force, a narrow limit layering account and IP is right. Different purposes mean different keys and different limits.
A fixed window can always let twice the limit through, and the cost of switching to a two-counter sliding window is close to nothing. After that, what remains is telling the client where it stands. Give it an accurate remaining count and retry time and a well-built client backs off on its own. Tell it nothing and everyone knocks on the door again at the same instant.