带退避重试的安全 HTTP 客户端
一、重试放大的是权限,不只是流量
批处理任务调用内部 API,偶发 503 就套了一层 for 循环加 sleep。上线两周后出了三件事:一次 POST 因重试重复建了两笔工单;一次 302 跳转把 Authorization 头原样带到了外部域;一次对端限流,重试把 5 QPS 放大成 40 QPS,直接把对方打挂。
重试本身不产生新权限,但它会把已有的凭据、写操作、连接数成倍放大。所以设计重试的第一步不是算退避曲线,而是先回答:凭据会流到哪里、哪些操作能被重复执行、最坏情况下会占用多少资源。这三问对应四条边界——只重试幂等方法、不跟随跳转、TLS 必须校验、整次调用有总时间预算。
二、先把边界写成可验证的检查
不要等到写代码才想边界。先用命令行确认对端行为:503 时是否返回 Retry-After,跳转是否可控,是否有明文降级入口。
# 只允许 https,最多 5 秒,打印状态码与跳转次数,不落盘
curl -sS -o /dev/null -w 'code=%{http_code} redirects=%{num_redirects}\n' \
--proto '=https' --max-time 5 --max-redirs 0 \
-H 'Authorization: Bearer test-token' \
https://api.internal.example.com/healthz
--proto '=https' 拦住降级到 http 的重定向,--max-redirs 0 让跳转直接暴露成 3xx,而不是被悄悄跟随。这两条应作为客户端实现的最低要求。
三、实现:白名单、幂等、预算
下面的实现把四个约束落在代码里。关键点:trust_env=False 让进程忽略 http_proxy 等环境变量,防止代理劫持;allow_redirects=False 阻止凭据随跳转外流;timeout 用元组分别约束连接与读取;退避采用 full jitter,避免重试在同一时刻聚焦成尖峰。
import random, time, urllib.parse, requests
ALLOWED_HOSTS = {"api.internal.example.com"}
IDEMPOTENT = {"GET", "HEAD", "PUT", "DELETE"} # 非幂等一律不进重试通道
RETRY_STATUS = {429, 502, 503, 504}
MAX_ATTEMPTS, MAX_DELAY, BUDGET = 4, 8.0, 20.0
def make_session(token):
s = requests.Session()
s.trust_env = False # 忽略代理环境变量
s.headers.update({"Authorization": f"Bearer {token}"})
return s
def call(session, method, url, **kw):
method = method.upper()
if method not in IDEMPOTENT:
raise ValueError("非幂等请求不重试")
parts = urllib.parse.urlsplit(url)
if parts.scheme != "https" or parts.hostname not in ALLOWED_HOSTS:
raise ValueError(f"目标不在白名单: {url}")
kw.setdefault("timeout", (3.05, 10))
kw.setdefault("allow_redirects", False) # 3xx 直接返回,不跟跳转
deadline = time.monotonic() + BUDGET
last = None
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
r = session.request(method, url, **kw)
if r.status_code not in RETRY_STATUS:
return r
last = requests.HTTPError(f"HTTP {r.status_code}", response=r)
r.close()
except (requests.ConnectionError, requests.Timeout) as e:
last = e
if attempt == MAX_ATTEMPTS:
break
cap = min(MAX_DELAY, 0.5 * 2 ** (attempt - 1))
delay = random.uniform(0, cap) # full jitter
if time.monotonic() + delay > deadline:
break
time.sleep(delay)
raise last
三点容易被忽略:响应返回 429 时读一下 Retry-After 比自算退避更尊重对端;BUDGET 是整次调用的总预算,不是单次超时,防止 4 次重试串起来拖住线程;异常对象保留下来统一 raise,比吞掉更利于排查。
四、落地:把进程也关进笼子
代码里的白名单是应用层约束,进程层再加一层,即使代码被改坏也不会打到任意网段。用 systemd 的非 root 用户加出站地址限制:
# /etc/systemd/system/httpclient.service
[Service]
User=httpclient
Group=httpclient
Environment=HTTPS_PROXY=
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
RestrictAddressFamilies=AF_INET AF_INET6
IPAddressDeny=any
IPAddressAllow=10.0.0.0/8
MemoryMax=256M
TasksMax=64
IPAddressDeny=any 配合 IPAddressAllow 只放行内网段,能挡掉误配到元数据地址(169.254.169.254)的请求。另外记得日志里对 Authorization、Set-Cookie 做脱敏,别让重试日志成为凭据泄漏面。
五、小结
带退避重试的客户端,难点不在退避算法,而在划定“可以被重复执行”的范围:非幂等请求不重试、不跟随跳转、TLS 不关校验、整次调用有总预算,再用主机白名单和 systemd 出站限制兜底。先用手工 curl 确认对端行为,再把约束写成代码断言,最后用进程级权限收口——三步做完,重试才真正只是重试。
- 点赞
- 收藏
- 关注作者
评论(0)