带退避重试的安全 HTTP 客户端

举报
yd_281239125 发表于 2026/11/04 13:10:42 2026/11/04
【摘要】 批处理任务调用内部 API,偶发 503 就套了一层 `for` 循环加 `sleep`。上线两周后出了三件事:一次 POST 因重试重复建了两笔工单;一次 302 跳转把 `Authorizatio

一、重试放大的是权限,不只是流量

批处理任务调用内部 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 确认对端行为,再把约束写成代码断言,最后用进程级权限收口——三步做完,重试才真正只是重试。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。