Vận hành rate limit — header, retry + jitter, giám sát, shadow mode và ADR

Rate limit là một hợp đồng hai bên. Server quyết định chặn; nhưng chặn xong thì client làm gì mới quyết định hệ thống khá lên hay tệ đi: client retry ngay lập tức nhân tải lên gấp 69 lần, client ngoan ngoãn chờ đúng Retry-After lại cùng quay về một lúc. Bài cuối của series đi qua cả hai phía trong shop của ví dụ: phía server (header, ra mắt limit mới an toàn, giám sát, xử lý sự cố) và phía client — cả đối tác gọi vào API của shop, lẫn chính app Laravel của shop gọi ra API đối tác (vận chuyển, thanh toán).

Thuật ngữ dùng trong bài
Retry-After (header báo client chờ bao nhiêu giây hoặc tới thời điểm nào) · exponential backoff (lần chờ sau gấp đôi lần trước) · jitter (cộng một khoảng ngẫu nhiên vào thời gian chờ để các client không quay lại cùng lúc) · retry storm (bão retry: lỗi sinh ra retry, retry sinh thêm tải, tải sinh thêm lỗi) · thundering herd (đàn đông: rất nhiều client cùng thức dậy một lúc) · retry budget (ngân sách retry: retry không vượt X% số request gốc) · shadow mode / dry run / dark launch (tính toán như thật nhưng không chặn, chỉ ghi log) · kill switch (cần gạt tắt tính năng ngay, không cần deploy) · idempotency (gửi lại nhiều lần cũng chỉ có tác dụng một lần).
Kiểm chứng: RFC 6585 §4; RFC 9110 §10.2.3; draft IETF draft-ietf-httpapi-ratelimit-headers-11 (23/05/2026); AWS Builders' Library "Timeouts, retries, and backoff with jitter" (Marc Brooker); Google SRE Book ch. 21 "Handling Overload"; Stripe 2017; GitHub Docs; Discord Developer Docs; nginx docs (limit_req_dry_run, $limit_req_status, limit_req_log_level); README envoyproxy/ratelimit; source Laravel 12.64 (Http\Client\PendingRequest::retry, Cache\RateLimiting\Limit::response). Đọc ngày 02/10/2026. Lab chạy thật: 1 000 client Node 22 với 5 chiến lược retry (out/06-retry.txt); Laravel 12.64 HTTP client gọi API đối tác giả (out/06-laravel-http.txt); nginx 1.31.3 dry run (out/05-nginx.txt).

Trả lời 30 giây

Phía server: trả 429 + Retry-After (không phải 503), thêm header còn lại bao nhiêu và body nói limiter nào chặn; ra mắt mọi limit mới ở shadow mode trước (nginx limit_req_dry_run ghi REJECTED_DRY_RUN vào log), có cần gạt tắt nhanh, có metric theo limiter × route × khách, và cảnh báo cả khi limiter tự hỏng (fail-open). Phía client: retry phải có giới hạn và có jitter — lab 1 000 client: retry ngay lập tức nhân tải ×68,7; chờ đúng Retry-After thì giảm còn ×5,2 nhưng tạo đỉnh 441 request/100ms ở mỗi đầu giây; cộng thêm jitter 0–1 giây thì ×3,9, đỉnh 303 và rải đều. Và đừng tin thư viện mặc định: Http::retry(3, 100) của Laravel bỏ qua Retry-After (3 request cách nhau ~100ms rồi ném 429) và gửi lại cả POST bị 422 — phải tự đọc header và chỉ retry 429/5xx/lỗi mạng.

1. Bài toán gốc và bối cảnh ra đời

Ba hiện tượng làm một hệ thống có rate limit vẫn sập:

Phần server (header, rollout, giám sát) và phần client (retry, backoff, jitter) phải được thiết kế cùng nhau; một bên tốt không bù được bên kia.

2. Mô hình tư duy cốt lõi

  1. Retry là ích kỷ. AWS: "retries are selfish. Retries are a way for clients to assert the importance of their request and demand that the service spend more of its resources to handle it." Nên retry phải có trần (số lần, ngân sách), có khoảng chờ tăng dần, và có jitter.
  2. Mỗi limit mới là một thay đổi hành vi với khách hàng. Ra mắt như ra mắt tính năng: đo trước (shadow), bật dần, có cần gạt, có người nhìn số.

3. Internals và quyết định thiết kế

3.1 Hợp đồng phía server: trả gì khi chặn

HTTP/1.1 429 Too Many Requests
Retry-After: 12                                   ← RFC 9110: số nguyên giây (hoặc HTTP-date)
X-RateLimit-Limit: 600                            ← kiểu đang phổ biến (GitHub, Discord, Laravel)
X-RateLimit-Remaining: 0
RateLimit-Policy: "partner-pro";q=600;w=60        ← draft IETF 11: q = hạn mức, w = cửa sổ (giây)
RateLimit: "partner-pro";r=0;t=12                 ←                r = còn lại, t = số giây
Content-Type: application/json
Cache-Control: no-store                           ← RFC 6585: 429 "MUST NOT be stored by a cache"

{"error":"rate_limited","limiter":"partner-pro","retry_after":12,"docs":"https://…/rate-limits"}
RateLimiter::for('partner', fn (Request $r) => Limit::perMinute($r->partner()->plan->rpm)
    ->by('partner:' . $r->partner()->id)
    ->response(fn (Request $r, array $headers) => response()->json([
        'error' => 'rate_limited', 'limiter' => 'partner', 'retry_after' => $headers['Retry-After'] ?? null,
    ], 429, $headers + ['Cache-Control' => 'no-store'])));

nginx: luôn limit_req_status 429; (mặc định 503); nginx không tự gửi Retry-After — thêm bằng error_page 429 trỏ tới một location trả header đó (chưa dựng lab cho phần này).

3.2 Phía client: retry thế nào — lab 1 000 client

Server: fixed window 100 request/giây căn theo giây đồng hồ; 429 kèm Retry-After = số giây (nguyên) tới đầu cửa sổ sau. 1 000 client bắt đầu cùng lúc (như sau một sự cố, một lần deploy, một cron lúc 9h00), mỗi client cần đúng 1 request thành công. Năm chiến lược:

retry ngay, không chờ                | tổng request  68675 (×68.7) | xong hết sau 9.4s | đỉnh 1434 request/100ms
chờ đúng Retry-After                 | tổng request   5201 (×5.2) | xong hết sau 9.4s | đỉnh 441 request/100ms
    0–3s, mỗi ô 100ms: 0 140 413 290 142 0 0 0 0 0 0 24 440 263 73 0 0 0 0 0 0 13 441 272 0 0 0 0 0 0
backoff mũ 100ms·2^n, không jitter   | tổng request   7176 (×7.2) | xong hết sau 26.7s | đỉnh 679 request/100ms
backoff mũ + full jitter             | tổng request   6486 (×6.5) | xong hết sau 11.6s | đỉnh 747 request/100ms
Retry-After + jitter 0–1s            | tổng request   3949 (×3.9) | xong hết sau 10.9s | đỉnh 303 request/100ms
    0–3s, mỗi ô 100ms: 1 259 303 299 25 0 0 0 0 0 0 116 40 61 93 79 89 73 76 79 56 87 34 27 16 27 29 40 39 67
Số request tới server mỗi 100 mili giây trong 3 giây đầu Hai hàng cột. Chờ đúng Retry-After: các cột cao dồn ở 0,1 tới 0,4 giây, 1,1 tới 1,4 giây và 2,1 tới 2,3 giây, ở giữa bằng 0; đỉnh 441 request mỗi 100 mili giây. Retry-After cộng jitter: sau đợt đầu, từ giây 1,1 các cột thấp và đều khoảng 30 đến 116; đỉnh 303. 1 000 client, server 100 request/giây — số request tới server mỗi 100ms (lab, 0–3 giây) chờ đúngRetry-After Retry-After+ jitter 0–1s 441303 0s1s2s3s
Hình 1 — Chờ đúng Retry-After là "ngoan" nhưng tạo đàn đông: mọi client bị chặn trong giây 0 nhận Retry-After: 1, cùng quay lại ở giây 1,1–1,4, tranh 100 chỗ, phần còn lại nhận Retry-After: 1 tiếp. Cộng thêm jitter 0–1 giây, sau đợt đầu tải rải đều, tổng số request giảm từ 5 201 xuống 3 949.

AWS nhắc thêm một chi tiết vận hành: jitter cho việc định kỳ (cron, đồng bộ) nên tất định theo host — "we use a consistent method that produces the same number every time on the same host" — để sự cố lặp lại theo mẫu, dễ tìm nguyên nhân hơn.

3.3 App Laravel gọi API đối tác: Http::retry() làm gì thật?

API vận chuyển giả trả 429 + Retry-After: 2 cho tới khi đủ 2 giây kể từ lần gọi đầu; API tạo đơn giả luôn trả 422 (dữ liệu sai). Laravel 12.64:

1. Http::retry(3, 100)                                     → ném RequestException, HTTP 429 | đối tác nhận 3 request, cách nhau 147ms → 103ms
2. Http::retry(3, 100, throw: false)                       → HTTP 429 | đối tác nhận 3 request, cách nhau 104ms → 106ms
3. retry đọc Retry-After + jitter, chỉ retry 429/5xx/mạng → HTTP 200 | đối tác nhận 2 request, cách nhau 2364ms
4. Http::retry(3, 100)->post() vào API trả 422          → ném RequestException, HTTP 422 | đối tác nhận 3 request, cách nhau 107ms → 109ms
5. như 4 nhưng có điều kiện when (chỉ 429/5xx/mạng) → ném RequestException, HTTP 422 | đối tác nhận 1 request
Http::retry(
    3,
    fn (int $attempt, Throwable $e) => $e instanceof RequestException && $e->response->status() === 429
        ? (int) $e->response->header('Retry-After') * 1000 + random_int(0, 500)     // tôn trọng Retry-After, cộng jitter
        : 200 * 2 ** $attempt,                                                     // còn lại: backoff mũ
    fn (Throwable $e) => $e instanceof ConnectionException
        || ($e instanceof RequestException && in_array($e->response->status(), [429, 502, 503, 504], true)),
)->get($url);

Bẫy của chính đoạn code trên: (1) Retry-After có thể là HTTP-date chứ không phải số — (int) của một ngày là 0 → retry ngay; parse cả hai dạng nếu đối tác dùng ngày; (2) chờ trong request web giữ worker php-fpm — Retry-After: 60 là 60 giây worker đứng; gọi đối tác từ queue job và $this->release($retryAfter) thay vì sleep; (3) nhiều worker queue cùng gọi một đối tác thì cần limiter dùng chung phía mình — job middleware RateLimited trên cache dùng chung (bài scale queue worker: cache riêng từng VM là giới hạn ×N).

3.4 Retry nhân qua nhiều tầng, và ngân sách retry

Mỗi tầng retry 3 lần, database nhận 243 lần Năm tầng nối nhau: gateway, service A, service B, service C, database. Mỗi tầng thử tối đa 3 lần cho mỗi lần gọi nó nhận. Số lần gọi tăng 3, 9, 27, 81 rồi 243 lần ở database cho một request của người dùng. gateway service A service B service C database ×3×9 ×27×81 ×243 1 request của người dùng, database đang lỗi, mỗi tầng tự retry tối đa 3 lần AWS: "the load on the database will increase 243x, making it unlikely to ever recover"
Hình 2 — Phép nhân 3⁵ = 243 (ví dụ của AWS). Đúng lúc database cần ít tải nhất để hồi phục, nó nhận nhiều tải nhất. Chữa: chỉ retry ở một tầng (AWS: "our best practice is to retry at a single point in the stack"), và giới hạn tổng số retry bằng ngân sách.

3.5 Ra mắt limit mới: shadow mode trước

Stripe: "Dark launch each rate limiter to watch the traffic they would block". nginx có sẵn từ 1.17.1 — limit_req_dry_run on; tính như thật, không chặn, và biến $limit_req_status (từ 1.17.6) ghi kết quả vào access log. Lab, 10 request cùng lúc vào location dry run:

limit_req_dry_run on — 10 request cùng lúc: 200×10
1790925671.652 "/dryrun" client=- 200 PASSED
1790925671.652 "/dryrun" client=- 200 REJECTED_DRY_RUN
1790925671.652 "/dryrun" client=- 200 REJECTED_DRY_RUN
error log: 9 dòng "limiting requests, dry run, excess: … by zone …"
flowchart LR
  S["1. Shadow mode
tính, không chặn,
ghi log ai sẽ bị chặn"] --> A["2. Phân tích 1–2 tuần
ai bị chặn? khách thật?
đối tác? bot?"] A -->|"chặn nhầm"| F["Sửa key / con số
/ allowlist"] F --> S A -->|"ổn"| P["3. Bật thật cho 1 phần
một route, một tenant,
hoặc % traffic"] P --> M["4. Theo dõi 429,
ticket hỗ trợ,
chuyển đổi đơn hàng"] M -->|"có vấn đề"| K["Kill switch:
quay về shadow"] M -->|"ổn"| ALL["5. Bật toàn bộ"]

Ở tầng app: một middleware tính như thật (cùng key, cùng thuật toán) nhưng chỉ ghi log + metric khi "lẽ ra chặn", bật/tắt bằng biến cấu hình. Envoy ratelimit có sẵn shadow_mode ("whatever the outcome of the evaluation of the rule, the end-result will always be 'OK'") và thống kê riêng những quyết định bị ghi đè. Chú ý: shadow mode phải chạy trên traffic thật đủ lâu để gặp cả giờ cao điểm, ngày khuyến mãi, cron của đối tác.

3.6 Giám sát

Tín hiệuVì saoNgưỡng cảnh báo gợi ý
429 theo limiter × route × loại keyBiết tầng nào đang chặn, chặn aiTăng đột ngột so với cùng giờ tuần trước
Top key bị chặn, số key khác nhau mỗi phútPhân biệt 1 khách quá tay với một đợt xoay IPSố key mới tăng vọt
Near-limit (key đang dùng > 80% hạn mức)Báo trước cho đối tác sắp chạm trần; Envoy ratelimit mặc định 0,8Đối tác lớn chạm 80% liên tục
Số lần fail-open của limiterLimiter tắt mà không ai biết (bài 3: lỗi cấu hình bị nuốt)> 0 trong 5 phút
Độ trễ p99 của lệnh limiter, lỗi RedisLimiter thành điểm chậm của mọi requestp99 > vài ms
Status 499 ở nginx (client đóng trước khi có response)Dấu hiệu sớm của việc làm phí (bài 4)Tăng cùng lúc với độ trễ
Tổng request / số request gốcĐo mức khuếch đại do retry> 1,1

nginx ghi mỗi lần chặn/giữ vào error log (limiting requests, excess: … by zone "…", delaying request, …); mức log chỉnh bằng limit_req_log_level — docs: mức cho việc giữ request thấp hơn mức cho việc từ chối một bậc. Đừng để error log thành đường duy nhất thấy 429: tính metric từ access log ($status, $limit_req_status).

3.7 Sự cố lúc 3 giờ sáng

Tình huốngViệc đầu tiênSau đó
Đối tác báo "toàn 429"Dashboard theo API key: chạm hạn mức gói, hay limiter nào khác?Allowlist tạm có hạn; nếu đúng gói thì báo nâng gói/giãn lịch đồng bộ
429 tăng vọt sau deployĐổi gì? key, con số, thứ tự limit, trusted proxy (bài 5)?Quay cấu hình limit về bản cũ (kill switch), điều tra sau
Đang bị tấn côngSiết ở tầng rẻ nhất: CDN/WAF, nginx (không phụ thuộc Redis)Rà route bị nhắm, thêm limit theo hành động
Redis của limiter chếtXác nhận limiter đang fail-open (không phải 500), cảnh báo đã kêuTheo dõi traffic bất thường; bật limit tạm ở nginx nếu cần
Bão retryXem tổng/gốc; khách nào retry dồnTăng Retry-After, chặn hẳn client hư theo key, liên hệ đối tác

4. Đảm bảo và giới hạn

5. Failure modes

Sự cốBằng chứngChữa
Client retry ngay khi 429Lab: ×68,7 tảiTôn trọng Retry-After; chặn client hư
Đàn đông ở đầu cửa sổLab: đỉnh 441/100ms mỗi đầu giâyJitter phía client; phía server dùng token bucket/GCRA thay fixed window căn đồng hồ
Backoff đồng bộLab: ×7,2 và chậm nhất 26,7 giâyLuôn có jitter
Retry nhiều tầngAWS: 243×Retry ở một tầng, ngân sách retry
Retry 4xx / POST không idempotentLab: POST 422 gửi 3 lầnĐiều kiện when; idempotency key
Ra mắt limit không qua shadowKhách thật bị chặn, biết qua ticketShadow mode + phân tích + bật dần
Limiter fail-open không ai biếtBài 3: 12/12 request qua vì lỗi cấu hìnhCảnh báo theo số lần fail-open
CDN cache 429(RFC cấm)Cache-Control: no-store, kiểm cấu hình CDN

6. Vận hành trong production — checklist

7. So sánh và ma trận quyết định

Chiến lược retry (lab)Tổng requestXong hếtĐỉnh/100msDùng khi
Retry ngay×68,79,4s1 434Không bao giờ
Chờ đúng Retry-After×5,29,4s441Chỉ khi có ít client
Backoff mũ không jitter×7,226,7s679Không nên
Backoff mũ + full jitter×6,511,6s747Server không gửi Retry-After
Retry-After + jitter×3,910,9s303Mặc định khi có Retry-After
Thực thi rate limit ởƯuNhượcHợp với
CDN/WAF có rule rate limitChặn trước khi tới máy bạn, chịu được khối lượng lớnChỉ biết IP/header; ngôn ngữ rule riêng từng hãng (lock-in)Bot, DDoS, cào thô
nginxRẻ, nhanh, không phụ thuộc RedisTheo instance; ít ngữ cảnhGiới hạn toàn hệ thống, IP dồn dập
API gateway / EnvoyTập trung, có shadow mode, có thống kêThêm thành phần, cần đội platformNhiều service, API công khai lớn
Middleware appBiết user, gói cước, routeRequest đã tốn worker; mỗi service tự làm dễ lệchGói cước đối tác, route đắt
Nghiệp vụĐếm đúng thứ cần đếm (lần sai, thẻ, SMS)Muộn nhất, đắt nhấtĐăng nhập, OTP, thanh toán

8. Góc nhìn tech lead: ADR mẫu cho cả series

ADR-0xx: Rate limiting cho shop + API đối tác
Bối cảnh   : 1 cụm nginx + php-fpm (capacity đo 76 rps/instance, load test mở ngày …); storefront Livewire;
             đăng nhập Fortify; API đối tác gói Free/Pro; Redis riêng cho limiter.
Quyết định :
  1. Toàn hệ thống: nginx limit_conn 8/instance (4 worker + 4 chờ), status 429.          (bài 4)
  2. Storefront: token bucket 120/phút burst 30, key user id | IP (IPv6 /64);
     gắn cả route update của Livewire.                                                   (bài 4, 5)
  3. Đăng nhập: Lua nguyên tử; 5/phút theo email+IP; 10 lần sai/giờ theo email (->after);
     CAPTCHA thay vì khoá cứng.                                                          (bài 3, 5)
  4. API đối tác: GCRA theo API key → tenant, theo gói; quota tháng trong DB.            (bài 2, 5)
  5. trustProxies: danh sách dải LB + CDN tường minh, không dùng '*'.                    (bài 5)
  6. Limiter fail-open + cảnh báo; timeout Redis 200ms; storefront fail-open,
     đăng nhập fallback limiter cục bộ.                                                  (bài 3)
  7. Header: 429, Retry-After, X-RateLimit-*, body có tên limiter; no-store.             (bài 1, 6)
  8. Mọi limit mới: shadow ≥ 1 tuần → bật dần → kill switch.                             (bài 6)
Hệ quả     : khách sau CGNAT có thể chạm 120/phút (chấp nhận, theo dõi); đối tác phải xử lý 429
             đúng (tài liệu + ví dụ code retry); thêm 1 lệnh Redis/request (~0,2ms).
Đo lường   : dashboard 429 theo limiter, near-limit đối tác, fail-open, p99 limiter, tổng/gốc.
Xem lại    : mỗi release lớn, trước mùa khuyến mãi, khi nâng Laravel (12 → 13 đổi trustProxies '*').
Thoát      : định nghĩa limit ở 1 chỗ, interface allow(key, cost); chuyển sang gateway chỉ đổi nơi thực thi.

9. Anti-patterns và bài học thực tế

  1. Tin thư viện retry mặc định — Http::retry(3, 100) bỏ qua Retry-After, retry cả 422 (lab).
  2. Retry ở mọi tầng — 243×.
  3. Backoff không jitter — vẫn đồng bộ (lab: chậm nhất, ×7,2).
  4. sleep(Retry-After) trong request web — giữ worker php-fpm; dùng queue và release().
  5. Bật limit thẳng production không qua shadow.
  6. Chỉ nhìn error log nginx để biết ai bị chặn — thiếu metric theo khách.
  7. Không có bậc thang cho client hư — client bỏ qua 429 mãi mãi.

Nguồn công khai: AWS Builders' Library (Marc Brooker) — 243×, "retries are selfish", jitter cho việc định kỳ, token bucket trong AWS SDK từ 2016; Google SRE ch. 21 — ngân sách retry 3 lần/request và 10%/client, adaptive throttling K = 2; Stripe 2017 — dark launch, kill switch; Discord — ngưỡng 10 000 request lỗi/10 phút; GitHub — "wait for at least one minute before retrying" khi không có retry-after, và có thể cấm integration.

10. Câu hỏi phụ interviewer hay hỏi tiếp

Client nên làm gì khi nhận 429?
Chờ đúng Retry-After cộng jitter, giới hạn số lần, không retry 4xx khác; không có Retry-After thì backoff mũ + full jitter. Lab: ×3,9 so với ×68,7 khi retry ngay.
Vì sao cần jitter khi đã có backoff?
Backoff không jitter vẫn đồng bộ các client bắt đầu cùng lúc (lab: đỉnh 679, chậm nhất). Jitter phá sự đồng bộ.
Ra mắt một rate limit mới thế nào để không chặn nhầm khách?
Shadow mode (nginx limit_req_dry_run, Envoy shadow_mode, hoặc middleware chỉ log) qua ít nhất một chu kỳ cao điểm → phân tích ai sẽ bị chặn → bật dần → kill switch.
Header nào nên trả?
429 + Retry-After bắt buộc; X-RateLimit-* hoặc RateLimit-Policy/RateLimit (draft IETF); body nói limiter nào; no-store.
Giám sát rate limit thế nào?
429 theo limiter × route × khách, near-limit, fail-open, độ trễ limiter, tổng/gốc (khuếch đại do retry), 499.
Retry storm là gì, chặn thế nào?
Lỗi sinh retry, retry sinh thêm tải. Chặn: retry ở một tầng, ngân sách retry (≤ 10%), token bucket cho retry, jitter, client tự hạ tải khi tỉ lệ thành công thấp.
App Laravel gọi API đối tác bị 429 thì xử lý sao?
Gọi từ queue job, release($retryAfter) thay vì sleep; limiter dùng chung phía mình (RateLimited trên Redis); Http::retry với closure đọc Retry-After và điều kiện chỉ retry 429/5xx/lỗi mạng.

Phụ lục: lab đã chạy

Môi trường: Node 22.23.3 (server limiter chạy process riêng bằng fork, 1 000 client cùng process), Laravel 12.64 + PHP 8.3.33 (mock API đối tác bằng php -S trong cùng container, --network none), nginx 1.31.3.

cd labs/rate-limit
node/run.sh 06-retry.mjs      # 5 chiến lược retry, 1 000 client → out/06-retry.txt
./06-laravel-http.sh          # Http::retry với 429 + Retry-After và với 422 → out/06-laravel-http.txt
./05-nginx.sh                 # có phần limit_req_dry_run + $limit_req_status → out/05-nginx.txt

Chưa verify: nginx gửi Retry-After qua error_page 429; middleware shadow ở tầng app; adaptive throttling phía client (công thức Google SRE); hành vi CDN với 429; cách các đối tác thật (vận chuyển, thanh toán) trả Retry-After dạng ngày.