Kiểm tra các thiết lập retry, backoff và timeout trên toàn bộ chuỗi lệnh gọi để ngăn chặn bão retry (retry storm). Tính toán lịch trình độ trễ hàm mũ với jitter dạng full, equal hoặc decorrelated, độ trễ trường hợp xấu nhất cho mỗi tầng, số lần thử kỳ vọng và mức độ khuếch đại yêu cầu. Cảnh báo các timeout bên trong kéo dài vượt quá thời gian của bên gọi, việc retry các lệnh gọi không bất biến (non-idempotent), retry trên mã lỗi 4xx, bỏ qua Retry-After cũng như thiếu giới hạn trần (cap) hoặc hạn chót (deadline). Bao gồm một công cụ tính toán dùng thư viện chuẩn Python có hỗ trợ --self-test.
---
name: retry-backoff-policy-calculator
description: Kiểm tra các thiết lập retry, backoff và timeout trên toàn bộ chuỗi lệnh gọi để ngăn chặn bão retry (retry storm). Tính toán lịch trình độ trễ hàm mũ với jitter dạng full, equal hoặc decorrelated, độ trễ trường hợp xấu nhất cho mỗi tầng, số lần thử kỳ vọng và mức độ khuếch đại yêu cầu. Cảnh báo các timeout bên trong kéo dài vượt quá thời gian của bên gọi, việc retry các lệnh gọi không bất biến (non-idempotent), retry trên mã lỗi 4xx, bỏ qua Retry-After cũng như thiếu giới hạn trần (cap) hoặc hạn chót (deadline). Bao gồm một công cụ tính toán dùng thư viện chuẩn Python có hỗ trợ --self-test.
---
# Công cụ tính toán chính sách Retry và Backoff
Bạn giúp các kỹ sư thiết lập chính sách retry, backoff và timeout giúp các dịch vụ hoạt động đáng tin cậy hơn thay vì biến một sự cố nhỏ thành một sự cố sập hệ thống diện rộng. Bạn thu thập các thiết lập của từng chặng (hop) trong chuỗi lệnh gọi (trình duyệt, gateway, các dịch vụ, cơ sở dữ liệu, API bên thứ ba), tính toán những gì thực sự diễn ra khi gặp lỗi, và đề xuất một chính sách an toàn hơn kèm theo các con số cụ thể.
## Các tệp trong kỹ năng này
- `scripts/retry_policy.py` - đọc JSON chuỗi lệnh gọi và in ra lịch trình độ trễ, độ trễ trường hợp xấu nhất, số lần thử kỳ vọng, mức độ khuếch đại và các cờ cảnh báo (chỉ sử dụng thư viện chuẩn Python 3)
- `references/backoff-math.md` - các công thức tính backoff, jitter, thời gian trường hợp xấu nhất, số lần thử kỳ vọng, mức độ khuếch đại và việc lồng ghép timeout
- `references/retry-safety-guide.md` - những gì an toàn để retry, khóa bất biến (idempotency key), ngắt mạch (circuit breaker), ngân sách retry (retry budget), các thiết lập thư viện phổ biến
- `templates/retry-policy-review.md` - báo cáo đánh giá gồm chuỗi hiện tại, các phát hiện và chính sách đề xuất
- `examples/checkout-chain.json` - một chuỗi thanh toán ba tầng với những sai lầm điển hình
## Quy trình làm việc
### 1. Lập bản đồ chuỗi lệnh gọi
Hỏi thông tin cho từng chặng, bắt đầu từ tầng ngoài cùng trước: số lần thử (attempts - tổng số lần thực hiện), độ trễ cơ sở (base delay), hệ số nhân (multiplier), độ trễ tối đa (max delay), loại jitter, timeout cho mỗi lần thử (per-try timeout), hạn chót tổng thể (overall deadline), điều kiện kích hoạt retry (mã trạng thái, "timeout", "network"), liệu lệnh gọi có tính bất biến (idempotent) hay không, có gửi kèm khóa bất biến (idempotency key) hay không, và có tuân thủ Retry-After hay không. Đọc các thông số này từ mã nguồn hoặc tệp cấu hình (SDK client, bộ chuyển đổi retry của HTTP client, các route Envoy/Istio, cấu hình dịch vụ gRPC, hàng đợi công việc). Với các giá trị chưa biết: sử dụng giá trị mặc định của thư viện và đánh dấu chúng là giả định.
### 2. Chạy công cụ tính toán
```
python3 scripts/retry_policy.py examples/checkout-chain.json
python3 scripts/retry_policy.py chain.json --failure-rate 0.5 --max-amplification 8
python3 scripts/retry_policy.py chain.json --json > retry-report.json
python3 scripts/retry_policy.py --self-test
```
Mã thoát (exit code) 1 có nghĩa là có các phát hiện mức độ nghiêm trọng cao, 2 có nghĩa là dữ liệu đầu vào không hợp lệ.
### 3. Giải thích các phát hiện
Thứ tự ưu tiên: RETRY_UNSAFE và AMPLIFICATION trước tiên (chúng gây ra các tác dụng phụ trùng lặp và làm sập hệ thống), tiếp theo là INNER_OUTLIVES_CALLER, HOT_LOOP, NO_PER_TRY_TIMEOUT, sau đó đến mức trung bình (medium) và thấp (low). Giải thích từng vấn đề bằng các con số trích từ báo cáo, ví dụ: "một lần thanh toán có thể biến thành 36 lệnh gọi tới nhà cung cấp dịch vụ thanh toán".
### 4. Đề xuất chính sách đã khắc phục
Sử dụng `references/backoff-math.md` và `references/retry-safety-guide.md`: chỉ retry tại một tầng duy nhất, dùng full jitter có giới hạn trần (cap), thời gian trường hợp xấu nhất của tầng bên trong phải nhỏ hơn per-try timeout của bên gọi, truyền hạn chót (deadline) xuống các tầng dưới, dùng khóa bất biến cho các thao tác ghi, không retry trên các lỗi 4xx ngoại trừ 408/429, tuân thủ Retry-After, áp dụng ngân sách retry hoặc bộ ngắt mạch. Chạy lại kịch bản trên tệp JSON đề xuất và hiển thị kết quả so sánh trước và sau khi sửa.
### 5. Viết báo cáo tổng hợp
Điền vào biểu mẫu `templates/retry-policy-review.md` và cung cấp các đoạn mã cấu hình (config snippets) cho thư viện mà người dùng đang sử dụng.
## Ví dụ kết quả
Đối với `examples/checkout-chain.json` ở tỷ lệ lỗi 30%: mức độ khuếch đại trường hợp xấu nhất là 36 lệnh gọi cho mỗi lần thanh toán (kỳ vọng là 2.74); thời gian trường hợp xấu nhất 9.3 giây của tầng gateway kéo dài vượt quá per-try timeout 8 giây của ứng dụng web; tầng thanh toán retry một lệnh trừ tiền không bất biến 4 lần mà không có khóa bất biến và retry cả lỗi 409; không có jitter tại tầng gateway; bỏ qua Retry-After trên lỗi 429/503.
## Quy tắc
- Không bao giờ khuyến nghị retry một lệnh gọi không bất biến mà không có khóa bất biến (idempotency key).
- Giữ tính trung thực của các con số: gắn nhãn rõ ràng các giá trị mặc định của thư viện và các phỏng đoán là giả định.
- Ưu tiên số lần retry ít hơn kèm theo thiết lập timeout hợp lý hơn là retry quá nhiều lần.
- Khuyến nghị kiểm thử tải (load test) hoặc tiêm lỗi (fault injection) để xác nhận chính sách trong thực tế, chứ không chỉ dựa vào tính toán lý thuyết.
FILE:references/backoff-math.md
# Toán học về Backoff và Timeout
## Thuật ngữ
- **attempts**: tổng số lần thử bao gồm cả lần đầu tiên. attempts 3 = 1 lần thử ban đầu + 2 lần retry.
- **base**: độ trễ của lần retry đầu tiên. **multiplier**: hệ số tăng trưởng (thường là 2). **cap**: độ trễ tối đa cho một lần chờ.
- **per-try timeout**: khoảng thời gian tối đa mà một lần thử được phép diễn ra trước khi bị hủy bỏ.
- **deadline**: tổng ngân sách thời gian cho toàn bộ lệnh gọi bao gồm tất cả các lần thử và thời gian chờ.
## Độ trễ trước lần retry thứ k (k = 1 .. attempts-1)
d(k) = min(cap, base x multiplier^(k-1))
| Jitter | Độ trễ | Phạm vi | Giá trị trung bình |
|---|---|---|---|
| none | d | d | d |
| full | random(0, d) | 0 .. d | d/2 |
| equal | d/2 + random(0, d/2) | d/2 .. d | 0.75 d |
| decorrelated | min(cap, random(base, 3 x previous)) | base .. min(cap, base x 3^k) | mô phỏng |
Full jitter phân tán tải tốt nhất và là khuyến nghị mặc định. Equal jitter giữ lại một khoảng thời gian chờ tối thiểu. Decorrelated jitter thích ứng với độ trễ liền trước đó và hoạt động tốt cho các client chạy dài hạn.
## Thời lượng trường hợp xấu nhất của một lệnh gọi
worst = attempts x per_try_timeout + tổng các độ trễ tối đa, sau đó cắt tại ngưỡng deadline nếu có thiết lập deadline.
Ví dụ: 3 lần thử x 1 s + 100 ms + 200 ms = 3.3 s.
## Số lần thử kỳ vọng
Với xác suất lỗi độc lập p cho mỗi lần thử và tổng số n lần thử (attempts):
E[tries] = 1 + p + p^2 + ... + p^(n-1)
p = 0.3, n = 3 -> 1.39. Trong trường hợp sập toàn bộ hệ thống, p = 1 và E = n.
## Sự khuếch đại qua các tầng
Nếu tầng A retry 3 lần và gọi B, B lại retry 3 lần và gọi C, C lại retry 4 lần, thì một yêu cầu từ người dùng có thể biến thành 3 x 3 x 4 = 36 lệnh gọi tới C khi xảy ra sự cố. Đây chính là bão retry (retry storm): các lệnh retry giữ cho dịch vụ đang gặp sự cố luôn trong tình trạng quá tải, khiến nó không thể phục hồi.
Quy tắc kinh nghiệm:
- Chỉ retry tại một tầng duy nhất, thường là tầng gần người dùng nhất hoặc tầng sở hữu khóa bất biến (idempotency key).
- Các tầng bên trong: đặt attempts là 1 hoặc 2 với hạn chót (deadline) ngắn.
- Giữ mức độ khuếch đại trong trường hợp xấu nhất ở mức bằng hoặc dưới khoảng 10.
- Thêm **ngân sách retry (retry budget)**: số lượng retry chỉ được chiếm tối đa 10-20% tổng số yêu cầu trong một cửa sổ trượt (sliding window); vượt quá ngưỡng đó, hãy báo lỗi ngay lập tức (fail fast).
## Lồng ghép Timeout (Timeout nesting)
Trường hợp xấu nhất của mỗi tầng phải nằm gọn bên trong per-try timeout của bên gọi nó, trừ đi một khoảng dự phòng (margin) cho độ trễ mạng và xử lý:
inner_worst <= outer_per_try_timeout - margin
Nếu không, bên gọi sẽ hủy bỏ lần thử đó và thực hiện retry trong khi tầng bên trong vẫn đang tiếp tục xử lý, làm tăng gấp đôi tải hệ thống. Hãy truyền một hạn chót (deadline) xuống dọc theo chuỗi lệnh gọi (ví dụ qua header `grpc-timeout` hoặc `X-Request-Deadline`) và dừng xử lý ngay khi thời gian này cạn kiệt.
## Retry-After
Đối với mã lỗi 429 và 503, máy chủ có thể gửi kèm `Retry-After: <seconds>` hoặc một ngày giờ theo chuẩn HTTP. Hãy chờ ít nhất bằng khoảng thời gian đó (sử dụng max(Retry-After, backoff tính toán được)), và từ bỏ ngay nếu khoảng thời gian chờ này dài hơn thời hạn (deadline) còn lại.
FILE:references/retry-safety-guide.md
# Hướng dẫn an toàn khi Retry
## Những trường hợp nên retry
| Kết quả | Có Retry không? | Ghi chú |
|---|---|---|
| Từ chối kết nối (Connection refused) / ngắt kết nối (reset) trước khi yêu cầu được gửi đi | Có | An toàn cho bất kỳ phương thức nào |
| Hết thời gian chờ (Timeout) sau khi yêu cầu đã được gửi đi | Chỉ khi có tính bất biến hoặc có khóa bất biến (idempotency key) | Máy chủ có thể đã thực hiện xong công việc đó |
| 408 Request Timeout | Có, kèm theo backoff | |
| 429 Too Many Requests | Có, sau khoảng thời gian Retry-After | Không bao giờ gọi lại nhanh hơn thời gian máy chủ yêu cầu |
| 500 Internal Server Error | Đôi khi | Chỉ khi biết chắc lỗi đó là lỗi tạm thời (transient) |
| 502 / 503 / 504 | Có, kèm theo backoff và jitter | Tuân thủ Retry-After trên lỗi 503 |
| 400 / 401 / 403 / 404 / 405 / 410 / 422 | Không | Cùng một yêu cầu sẽ thất bại theo cùng một cách; hãy sửa lại yêu cầu, hoặc làm mới xác thực (auth) tối đa một lần |
| 409 Conflict | Thường là không | Ngoại lệ: một khóa bất biến vẫn đang trong quá trình xử lý, hãy retry sau một khoảng chờ ngắn |
## Tính bất biến (Idempotency)
- GET, HEAD, OPTIONS, PUT và DELETE có tính bất biến theo ngữ nghĩa HTTP, nếu máy chủ triển khai chúng đúng chuẩn.
- POST và PATCH không có tính bất biến. Để retry chúng, hãy gửi kèm một **khóa bất biến (idempotency key)** (một mã UUID cho mỗi thao tác logic, ví dụ qua header `Idempotency-Key`) mà máy chủ sẽ lưu trữ cùng với kết quả trong vòng 24 giờ hoặc lâu hơn. Khởi tạo khóa này một lần duy nhất trước lần thử đầu tiên, và tái sử dụng lại nó cho mọi lần retry.
- Các tác vụ thanh toán, gửi email, SMS, tạo đơn hàng và giữ chỗ tồn kho tuyệt đối không bao giờ được retry nếu không có khóa bất biến.
## Danh sách kiểm tra cho Client (Client checklist)
1. Đã thiết lập per-try timeout và ngắn hơn timeout của bên gọi.
2. Đã thiết lập hạn chót tổng thể (overall deadline) và truyền xuống dọc theo chuỗi lệnh gọi.
3. Sử dụng exponential backoff có giới hạn trần (cap) và full jitter.
4. Số lần thử (attempts) <= 3 ở tầng biên (edge), 1-2 ở các tầng bên trong.
5. Chỉ retry trên các hàng có giá trị "Có" trong bảng hướng dẫn.
6. Đính kèm khóa bất biến (idempotency key) trên mọi lệnh gọi không bất biến có thực hiện retry.
7. Có ngân sách retry (retry budget) hoặc bộ ngắt mạch (circuit breaker) để dừng retry khi xảy ra sự cố diện rộng.
8. Các chỉ số đo lường (metrics): số lần thử trên mỗi yêu cầu, tỷ lệ retry, số lần từ bỏ (give-up count), phân loại theo từng dịch vụ phụ thuộc (dependency).
9. Chỉ ghi log lỗi cuối cùng kèm theo tổng số lần đã thử, không ghi log ở mức error cho từng lần thử thất bại riêng lẻ.
## Kiến thức cơ bản về Bộ ngắt mạch (Circuit breaker)
- Đóng (Closed): các lệnh gọi diễn ra bình thường; đếm số lỗi trong một cửa sổ trượt (rolling window).
- Mở (Open): sau khi vượt ngưỡng ví dụ như 50% lỗi trên ít nhất 20 lệnh gọi, báo lỗi ngay lập tức (fail fast) trong vòng 30 giây.
- Nửa mở (Half-open): cho phép một vài lệnh gọi thăm dò đi qua; chuyển về trạng thái Đóng nếu chúng thành công.
Sự kết hợp giữa bộ ngắt mạch và ngân sách retry sẽ ngăn chặn bão retry làm trầm trọng thêm sự cố sập hệ thống.
## Thiết lập của các thư viện phổ biến
- AWS SDKs: chế độ tiêu chuẩn (standard mode) = 3 lần thử (attempts), full jitter, độ trễ cơ sở (base) 1 giây khi bị giới hạn tốc độ (throttling); chế độ thích ứng (adaptive mode) bổ sung thêm tính năng giới hạn tốc độ phía client.
- Chính sách retry của gRPC: maxAttempts, initialBackoff, maxBackoff, backoffMultiplier, retryableStatusCodes (UNAVAILABLE là mã phổ biến nhất); việc retry cần tuân thủ các quy tắc hedging/transparent retry đối với các phương thức không bất biến.
- Envoy / Istio: retry_on: 5xx,reset,connect-failure; num_retries; per_try_timeout; thiết lập route timeout >= per_try_timeout x (num_retries + 1).
- Python `urllib3.Retry(total=3, backoff_factor=0.5, status_forcelist=[502,503,504], respect_retry_after_header=True, allowed_methods=[...])`.
Hãy kiểm tra tài liệu của thư viện tương ứng với phiên bản bạn đang sử dụng; các giá trị mặc định có thể thay đổi theo phiên bản.
FILE:templates/retry-policy-review.md
# Đánh giá Chính sách Retry - <dịch vụ / chuỗi lệnh gọi> - <ngày>
**Người đánh giá:** <tên> **Đầu vào:** <JSON chuỗi / các tệp cấu hình> **Tỷ lệ lỗi giả định:** <30%>
## Chuỗi lệnh gọi
| # | Bên gọi -> Bên được gọi | Số lần thử (Attempts) | Backoff (base / x / cap) | Jitter | Per-try timeout | Deadline | Trường hợp xấu nhất |
|---|---|---|---|---|---|---|---|
| 1 | | | | | | | |
**Mức độ khuếch đại trường hợp xấu nhất:** <36> lệnh gọi trên mỗi yêu cầu (mục tiêu <= 10)
**Mức độ khuếch đại kỳ vọng tại <p>:** <2.7>
## Các phát hiện
| Mức độ nghiêm trọng | Tầng | Mã lỗi | Vấn đề | Cách khắc phục |
|---|---|---|---|---|
| high | | | | |
## Chính sách đề xuất
| # | Bên gọi -> Bên được gọi | Số lần thử (Attempts) | Backoff | Jitter | Per-try timeout | Deadline | Retry khi gặp |
|---|---|---|---|---|---|---|---|
| 1 | | | | full | | | |
**Mức độ khuếch đại trường hợp xấu nhất mới:** <n> **Độ trễ trường hợp xấu nhất mới:** <s>
## Công việc tiếp theo
- [ ] Bổ sung khóa bất biến (idempotency keys) cho: <các lệnh gọi>
- [ ] Thiết lập ngân sách retry / bộ ngắt mạch cho: <dịch vụ phụ thuộc>
- [ ] Bảng điều khiển (Dashboards): số lần thử trên mỗi yêu cầu, tỷ lệ retry, số lần từ bỏ
FILE:examples/checkout-chain.json
{
"name": "checkout: browser -> api gateway -> orders service -> payments provider",
"failure_rate": 0.3,
"max_amplification": 10,
"layers": [
{
"name": "web app -> api gateway",
"attempts": 3,
"base_delay_ms": 200,
"multiplier": 2,
"max_delay_ms": 2000,
"jitter": "full",
"per_try_timeout_ms": 8000,
"deadline_ms": 20000,
"retry_on": ["network", 502, 503, 504],
"idempotent": true,
"respect_retry_after": true
},
{
"name": "api gateway -> orders service",
"attempts": 3,
"base_delay_ms": 100,
"multiplier": 2,
"max_delay_ms": 1000,
"jitter": "none",
"per_try_timeout_ms": 3000,
"retry_on": [500, 502, 503, 504, "timeout"],
"idempotent": true
},
{
"name": "orders service -> payments provider (create charge)",
"attempts": 4,
"base_delay_ms": 250,
"multiplier": 2,
"jitter": "equal",
"per_try_timeout_ms": 2500,
"deadline_ms": 9000,
"retry_on": [409, 429, 500, 503, "timeout"],
"idempotent": false,
"idempotency_key": false,
"respect_retry_after": false
}
]
}
FILE:scripts/retry_policy.py
#!/usr/bin/env python3
"""Retry and backoff policy calculator (Python 3 standard library only).
Input: a JSON file describing a call chain, outermost caller first:
{"name": "checkout", "failure_rate": 0.3, "max_amplification": 10,
"layers": [
{"name": "web -> api", "attempts": 3, "base_delay_ms": 100, "multiplier": 2,
"max_delay_ms": 2000, "jitter": "full", "per_try_timeout_ms": 5000,
"deadline_ms": 12000, "retry_on": [502, 503, 504, "timeout"],
"idempotent": true, "respect_retry_after": true}, ...]}
attempts = total tries including the first one (attempts 1 = no retries).
jitter: none | full | equal | decorrelated. Delay before retry k (k = 1..attempts-1):
none d = min(cap, base * multiplier^(k-1))
full U(0, d) equal d/2 + U(0, d/2)
decorrelated min(cap, U(base, 3 * previous)) (AWS style; expected value simulated, seed 7)
Reports per layer: delay schedule (min / expected / max), worst-case time for one call
(attempts x per-try timeout + max delays, cut at deadline), expected tries at the given
failure rate, and for the chain: worst-case and expected request amplification at the
innermost dependency. Flags:
NO_JITTER, NO_CAP, HOT_LOOP (base 0), TOO_MANY_ATTEMPTS (> 5), NO_PER_TRY_TIMEOUT,
NO_DEADLINE, DEADLINE_TOO_SHORT (deadline < one per-try timeout), INNER_OUTLIVES_CALLER
(worst case > the outer layer's per-try timeout), RETRY_UNSAFE (non-idempotent with
retries and no idempotency key), RETRY_ON_4XX (retrying 400/401/403/404/409/422),
IGNORES_RETRY_AFTER (retries 429/503 without honoring Retry-After), AMPLIFICATION.
Usage:
python3 retry_policy.py chain.json
python3 retry_policy.py chain.json --failure-rate 0.5 --json
python3 retry_policy.py --self-test
Exit code: 0 OK, 1 high-severity flags found, 2 bad input.
"""
import argparse
import json
import random
import sys
JITTERS = ("none", "full", "equal", "decorrelated")
BAD_4XX = {400, 401, 403, 404, 405, 409, 410, 422}
SEVERITY = {"INNER_OUTLIVES_CALLER": "high", "RETRY_UNSAFE": "high", "AMPLIFICATION": "high",
"HOT_LOOP": "high", "NO_PER_TRY_TIMEOUT": "high", "RETRY_ON_4XX": "medium",
"NO_JITTER": "medium", "NO_CAP": "medium", "IGNORES_RETRY_AFTER": "medium",
"NO_DEADLINE": "medium", "DEADLINE_TOO_SHORT": "medium", "TOO_MANY_ATTEMPTS": "low"}
class PolicyError(ValueError):
pass
def num(layer, key, default=None, minimum=0):
v = layer.get(key, default)
if v is None:
return None
if isinstance(v, bool) or not isinstance(v, (int, float)):
raise PolicyError(f"layer {layer.get('name', '?')!r}: {key} must be a number")
if v < minimum:
raise PolicyError(f"layer {layer.get('name', '?')!r}: {key} must be >= {minimum}")
return v
def delays(layer):
"""Return [(min, expected, max)] in ms for each retry."""
n = int(layer["attempts"])
base, mult, cap = layer["base_delay_ms"], layer["multiplier"], layer["max_delay_ms"]
capf = (lambda x: x) if cap is None else (lambda x: min(cap, x))
j = layer["jitter"]
out = []
if j == "decorrelated":
rng = random.Random(7)
runs = 20000
sums = [0.0] * (n - 1)
for _ in range(runs):
prev = base
for k in range(n - 1):
prev = capf(rng.uniform(base, prev * 3)) if base > 0 else 0.0
sums[k] += prev
hi = base
for k in range(n - 1):
hi = capf(hi * 3)
out.append((capf(base), sums[k] / runs, hi))
return out
for k in range(n - 1):
d = capf(base * mult ** k)
if j == "none":
out.append((d, d, d))
elif j == "full":
out.append((0.0, d / 2, d))
else:
out.append((d / 2, d * 0.75, d))
return out
def normalize(raw):
layers = raw.get("layers") if isinstance(raw, dict) else None
if not layers or not isinstance(layers, list):
raise PolicyError("JSON must have a non-empty 'layers' list (outermost caller first)")
out = []
for i, l in enumerate(layers):
if not isinstance(l, dict):
raise PolicyError(f"layer {i} must be an object")
l = dict(l)
l.setdefault("name", f"layer {i + 1}")
a = num(l, "attempts", 1, 1)
if int(a) != a:
raise PolicyError(f"layer {l['name']!r}: attempts must be a whole number")
l["attempts"] = int(a)
l["base_delay_ms"] = num(l, "base_delay_ms", 0)
l["multiplier"] = num(l, "multiplier", 2, 1)
l["max_delay_ms"] = num(l, "max_delay_ms", None)
l["per_try_timeout_ms"] = num(l, "per_try_timeout_ms", None, 1)
l["deadline_ms"] = num(l, "deadline_ms", None, 1)
j = str(l.get("jitter", "none")).lower()
if j not in JITTERS:
raise PolicyError(f"layer {l['name']!r}: jitter must be one of {', '.join(JITTERS)}")
l["jitter"] = j
l["retry_on"] = list(l.get("retry_on", []))
l["idempotent"] = bool(l.get("idempotent", True))
l["idempotency_key"] = bool(l.get("idempotency_key", False))
l["respect_retry_after"] = bool(l.get("respect_retry_after", False))
out.append(l)
return out
def expected_tries(n, p):
if p >= 1:
return float(n)
return sum(p ** k for k in range(n))
def analyze(raw, failure_rate=None, max_amp=None):
layers = normalize(raw)
p = failure_rate if failure_rate is not None else raw.get("failure_rate", 0.2)
if not 0 <= p <= 1:
raise PolicyError("failure rate must be between 0 and 1")
max_amp = max_amp if max_amp is not None else raw.get("max_amplification", 10)
res = {"name": raw.get("name", "retry policy"), "failure_rate": p, "max_amplification": max_amp, "layers": []}
worst_amp, exp_amp = 1, 1.0
for i, l in enumerate(layers):
n = l["attempts"]
ds = delays(l)
flags = []
retries = n > 1
if retries and l["jitter"] == "none":
flags.append(("NO_JITTER", "all clients retry at the same moments; use full or decorrelated jitter"))
if retries and l["max_delay_ms"] is None and n > 3:
flags.append(("NO_CAP", "no max_delay_ms; exponential delays grow without bound"))
if retries and l["base_delay_ms"] == 0:
flags.append(("HOT_LOOP", "base_delay_ms is 0; retries fire immediately and hammer the dependency"))
if n > 5:
flags.append(("TOO_MANY_ATTEMPTS", f"{n} attempts; 3 (2 retries) is usually enough"))
if l["per_try_timeout_ms"] is None:
flags.append(("NO_PER_TRY_TIMEOUT", "no per-try timeout; one hung call can block every retry"))
if l["deadline_ms"] is None and retries:
flags.append(("NO_DEADLINE", "no overall deadline across attempts"))
if l["deadline_ms"] is not None and l["per_try_timeout_ms"] and l["deadline_ms"] < l["per_try_timeout_ms"]:
flags.append(("DEADLINE_TOO_SHORT", "deadline is shorter than a single try"))
if retries and not l["idempotent"] and not l["idempotency_key"]:
flags.append(("RETRY_UNSAFE", "retries a non-idempotent call without an idempotency key (double charge / duplicate write risk)"))
bad = sorted(c for c in l["retry_on"] if isinstance(c, int) and c in BAD_4XX)
if retries and bad:
flags.append(("RETRY_ON_4XX", f"retries client errors {bad}; they will fail the same way again"))
if retries and any(c in (429, 503) for c in l["retry_on"]) and not l["respect_retry_after"]:
flags.append(("IGNORES_RETRY_AFTER", "retries 429/503 but ignores the Retry-After header"))
max_delay_total = sum(d[2] for d in ds)
exp_delay_total = sum(d[1] for d in ds)
worst = None
if l["per_try_timeout_ms"] is not None:
worst = n * l["per_try_timeout_ms"] + max_delay_total
if l["deadline_ms"] is not None:
worst = min(worst, l["deadline_ms"])
elif l["deadline_ms"] is not None:
worst = l["deadline_ms"]
if i > 0:
parent = layers[i - 1]
pt = parent["per_try_timeout_ms"]
if pt is not None and (worst is None or worst > pt):
flags.append(("INNER_OUTLIVES_CALLER", f"worst case {fmt_ms(worst)} > caller per-try timeout {fmt_ms(pt)}; the caller gives up and retries while this layer is still working"))
et = expected_tries(n, p)
worst_amp *= n
exp_amp *= et
res["layers"].append({
"name": l["name"], "attempts": n, "jitter": l["jitter"],
"delays_ms": [[round(a, 1), round(b, 1), round(c, 1)] for a, b, c in ds],
"max_delay_total_ms": round(max_delay_total, 1), "expected_delay_total_ms": round(exp_delay_total, 1),
"per_try_timeout_ms": l["per_try_timeout_ms"], "deadline_ms": l["deadline_ms"],
"worst_case_ms": None if worst is None else round(worst, 1),
"expected_tries": round(et, 3), "cumulative_worst_amplification": worst_amp,
"cumulative_expected_amplification": round(exp_amp, 3),
"flags": [{"code": c, "severity": SEVERITY[c], "detail": d} for c, d in flags]})
if worst_amp > max_amp:
res["layers"][-1]["flags"].append({"code": "AMPLIFICATION", "severity": "high",
"detail": f"one top-level request can become {worst_amp} calls to the innermost dependency during an outage (limit {max_amp}); retry at one layer only or add a retry budget"})
res["worst_amplification"] = worst_amp
res["expected_amplification"] = round(exp_amp, 3)
res["high_flags"] = sum(1 for l in res["layers"] for f in l["flags"] if f["severity"] == "high")
return res
def fmt_ms(v):
if v is None:
return "unbounded"
if v >= 1000:
s = f"{v / 1000:.2f}".rstrip("0").rstrip(".")
return s + " s"
return f"{v:g} ms"
def to_markdown(r):
out = [f"# Retry policy report: {r['name']}", "",
f"Failure rate used for expected values: {r['failure_rate']:.0%}", "",
"| Layer | Attempts | Jitter | Max total delay | Per-try timeout | Deadline | Worst case | Expected tries |",
"|---|---|---|---|---|---|---|---|"]
for l in r["layers"]:
out.append(f"| {l['name']} | {l['attempts']} | {l['jitter']} | {fmt_ms(l['max_delay_total_ms'])} | "
f"{fmt_ms(l['per_try_timeout_ms'])} | {fmt_ms(l['deadline_ms'])} | {fmt_ms(l['worst_case_ms'])} | {l['expected_tries']:g} |")
out += ["", "## Delay schedule (min / expected / max)"]
for l in r["layers"]:
if not l["delays_ms"]:
out.append(f"- {l['name']}: no retries")
continue
parts = [f"retry {k + 1}: {fmt_ms(a)} / {fmt_ms(b)} / {fmt_ms(c)}" for k, (a, b, c) in enumerate(l["delays_ms"])]
out.append(f"- {l['name']}: " + "; ".join(parts))
out += ["", "## Amplification at the innermost dependency",
f"- Worst case (every try fails): {r['worst_amplification']} calls per top-level request",
f"- Expected at {r['failure_rate']:.0%} failure rate: {r['expected_amplification']:g} calls per top-level request", "",
"## Findings"]
n = 0
for sev in ("high", "medium", "low"):
for l in r["layers"]:
for f in l["flags"]:
if f["severity"] == sev:
n += 1
out.append(f"- **{sev.upper()} {f['code']}** ({l['name']}): {f['detail']}")
if not n:
out.append("- No findings.")
return "\n".join(out)
def self_test():
cases = []
def ok(name, cond):
cases.append((name, bool(cond)))
def one(**kw):
base = {"name": "x", "attempts": 3, "base_delay_ms": 100, "multiplier": 2, "max_delay_ms": 1000,
"jitter": "full", "per_try_timeout_ms": 1000, "deadline_ms": 5000, "retry_on": [503],
"respect_retry_after": True}
base.update(kw)
return base
def codes(r, i=0):
return {f["code"] for f in r["layers"][i]["flags"]}
d = delays(normalize({"layers": [one(jitter="none", attempts=5)]})[0])
ok("exponential no jitter 100,200,400,800", [x[2] for x in d] == [100, 200, 400, 800])
d = delays(normalize({"layers": [one(jitter="none", attempts=6)]})[0])
ok("cap applied at 1000", d[-1][2] == 1000 and d[-2][2] == 800)
d = delays(normalize({"layers": [one(jitter="full")]})[0])
ok("full jitter mean half", d[0] == (0.0, 50.0, 100))
d = delays(normalize({"layers": [one(jitter="equal")]})[0])
ok("equal jitter 50..100 mean 75", d[0] == (50.0, 75.0, 100))
d = delays(normalize({"layers": [one(jitter="decorrelated", attempts=4)]})[0])
ok("decorrelated within bounds", all(a <= b <= c for a, b, c in d) and d[0][2] == 300 and d[2][2] == 1000)
r = analyze({"layers": [one()]})
ok("clean policy has no flags", not codes(r))
ok("worst case = 3x1000 + 100 + 200", r["layers"][0]["worst_case_ms"] == 3300)
r = analyze({"layers": [one(deadline_ms=2000)]})
ok("deadline caps worst case", r["layers"][0]["worst_case_ms"] == 2000)
ok("expected tries p=0.5 n=3 is 1.75", expected_tries(3, 0.5) == 1.75 and expected_tries(3, 1) == 3)
ok("no jitter flagged", "NO_JITTER" in codes(analyze({"layers": [one(jitter="none")]})))
ok("hot loop flagged", "HOT_LOOP" in codes(analyze({"layers": [one(base_delay_ms=0)]})))
ok("no cap flagged with 4+ attempts", "NO_CAP" in codes(analyze({"layers": [one(max_delay_ms=None, attempts=4)]})))
ok("unsafe retry flagged", "RETRY_UNSAFE" in codes(analyze({"layers": [one(idempotent=False)]})))
ok("idempotency key makes it safe", "RETRY_UNSAFE" not in codes(analyze({"layers": [one(idempotent=False, idempotency_key=True)]})))
ok("4xx retry flagged", "RETRY_ON_4XX" in codes(analyze({"layers": [one(retry_on=[400, 503])]})))
ok("Retry-After ignored flagged", "IGNORES_RETRY_AFTER" in codes(analyze({"layers": [one(retry_on=[429], respect_retry_after=False)]})))
ok("single attempt is not a retry policy", not codes(analyze({"layers": [one(attempts=1, jitter="none", deadline_ms=None)]})))
r = analyze({"layers": [one(per_try_timeout_ms=2000, deadline_ms=None), one(per_try_timeout_ms=1000, deadline_ms=None)]})
ok("inner outlives caller flagged", "INNER_OUTLIVES_CALLER" in codes(r, 1))
r = analyze({"layers": [one(per_try_timeout_ms=4000), one(per_try_timeout_ms=1000, deadline_ms=3500)]})
ok("inner within caller timeout ok", "INNER_OUTLIVES_CALLER" not in codes(r, 1))
r = analyze({"layers": [one(), one(), one()], "max_amplification": 10})
ok("3x3x3 amplification 27 flagged", r["worst_amplification"] == 27 and "AMPLIFICATION" in codes(r, 2))
r = analyze({"layers": [one(), one()]}, failure_rate=0.5)
ok("expected amplification 1.75^2", r["expected_amplification"] == 3.062)
for bad, label in ((({"layers": []}), "empty layers rejected"), (({"layers": [one(jitter="random")]}), "bad jitter rejected"),
(({"layers": [one(attempts=0)]}), "zero attempts rejected"), (({"layers": [one(attempts=2.5)]}), "fractional attempts rejected")):
try:
analyze(bad)
ok(label, False)
except PolicyError:
ok(label, True)
ok("fmt_ms", fmt_ms(1500) == "1.5 s" and fmt_ms(250) == "250 ms" and fmt_ms(None) == "unbounded")
passed = sum(c for _, c in cases)
for name, c in cases:
print(("PASS " if c else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Check retry/backoff/timeout policies across a call chain.")
ap.add_argument("file", nargs="?", help="chain JSON ('-' for stdin)")
ap.add_argument("--failure-rate", type=float, help="probability that one try fails (default from file or 0.2)")
ap.add_argument("--max-amplification", type=float, help="flag chains above this worst-case amplification (default 10)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("chain JSON required (or --self-test)")
try:
raw = json.load(sys.stdin if a.file == "-" else open(a.file, encoding="utf-8"))
r = analyze(raw, a.failure_rate, a.max_amplification)
except (OSError, PolicyError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2) if a.json else to_markdown(r))
return 1 if r["high_flags"] else 0
if __name__ == "__main__":
sys.exit(main())