Retry-After 告诉客户端,在发起后续请求之前应当等待多久。它是调度信号,不证明下一次请求一定成功,也不证明重放原操作是安全的。客户端仍然需要截止期限、尝试次数政策,以及哪些操作可以重试的判断,不能把这些责任都交给一个响应头。

结合响应语境解释字段值

RFC 9110 允许非负十进制整数秒数,或者 HTTP 日期。秒数从收到响应之后开始计算,不是从发出原请求时算起;日期则表示一个绝对 UTC 时刻,因此正确解释它依赖客户端墙上时钟足够准确,不能把两个形式都当成“从请求开始算多久”。

发送方必须使用首选的 IMF-fixdate 格式。解析 HTTP 日期的接收方还必须接受两种旧格式,其中旧 RFC 850 格式的两位年份需要遵守五十年解释规则。负数、小数秒和任意文字,都不是合法的 delay-seconds;零和已经过去的日期,则不要求增加服务器指定的等待时间。

配合 503 时,该字段估计服务还会对客户端不可用多久;配合 3xx 响应时,它要求在发送重定向请求之前至少等待相应时间。RFC 6585 另行允许 429 携带 Retry-After,但这里是可选字段,不是每一条限流响应都必须包含的内容。

尊重等待时间,但不无限等待

一个合法等待值可能长于调用方剩余期限。客户端可以停止重试并报告失败,或者在另有授权的流程中安排后续处理;不能只是为了符合本地上限,就缩短服务器要求的等待,然后提前发起请求。很长的值并不自动等于格式错误,也可能是真实的维护安排。

把绝对日期换算成等待时长后,执行调度和经过时间记账应使用单调时钟。真正开始下一次尝试之前,还应重新检查期限,并为这次请求本身保留完成时间。时钟偏差、响应传输和调度延迟,都说明计算出的计划时间与实际请求时间不是同一份证据。

如果字段缺失或无法使用,有界客户端政策可以采用自己的退避规则。但那是客户端的选择,不是服务器给出的恢复估计。必须明确最多尝试几次、总共允许耗费多久,避免一个可选字段让暂时失败变成没有终点的重试循环。

相同等待值仍可能让调用者同时回来

如果成千上万的客户端几乎同时收到相同等待值,它们仍可能在同一个时刻返回。Retry-After 本身不会把这些请求自动摊开到恢复窗口。可以在尊重要求的等待后增加非负抖动,或者用其他不早于该边界的调度方式,但不能向前减掉等待来制造错峰。

组合本地退避和服务器提示时,需要同时保留两者约束。示例取本地退避与解析出的等待值中较大者,再添加正向抖动。注入的偏移量展示的是调度关系,不是实际分布式流量的测量结果,也不是对某个生产环境抖动范围的推荐。

坏掉的样子

当原请求结果未知时,盲目重放写操作可能重复产生副作用。429、503 和 Retry-After 都不会赋予操作幂等保证;重复非幂等工作之前,需要依据应用自己的重试契约,包括操作标识或去重规则,不能只检查响应头是否存在。

不同客户端库的自动重试和重定向行为也不相同。应验证真实客户端会处理哪些状态、方法和字段。实验政策只对 429 或 503 后的 GET、HEAD 做重试计划,明确不跟随重定向、不重放 POST。这是一个有界示例政策,不是 HTTP 所有幂等方法的完整名单。

非法值、巨大等待、过时日期、耗尽的截止期限和次数上限,都应进入测试。应观察真正的请求尝试与等待,而不是认为配置了响应头就代表客户端已经遵守。服务器也可能在估计时间之后仍不可用,所以重试流程需要一个清楚的终止结果。

快速验证

保存为 retry_check.py,用 Python 3.10 以上版本执行 python3 retry_check.py。此前记录使用 Python 3.14.4,本轮没有重跑;仅用标准库。代码向自己创建的回环 HTTP 服务发送真实请求,使用固定测试日期和注入时钟,结束后停止服务;不会实际等待到下一次重试,也不访问外部服务。

from datetime import datetime, timedelta, timezone
from email.utils import format_datetime, parsedate_tz
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, HTTPServer
import re
from threading import Thread


UTC = timezone.utc
REFERENCE = datetime(2026, 9, 8, 12, 0, 0, tzinfo=UTC)


def delay_seconds(value, received_wall):
    if value is None:
        return None
    value = value.strip(" \t")
    if re.fullmatch(r"[0-9]+", value):
        # Huge decimal values become infinity and fail the bounded retry budget.
        return float(value)
    parts = parsedate_tz(value)
    if parts is None:
        return None
    year, month, day, hour, minute, second = parts[:6]
    old_date = re.fullmatch(r"[A-Za-z]+, [0-9]{2}-[A-Za-z]{3}-([0-9]{2}) [0-9:]{8} GMT", value)
    if old_date:
        now = datetime.fromtimestamp(received_wall, UTC)
        year = now.year // 100 * 100 + int(old_date[1])
        if (year, month, day, hour, minute, second) > (
            now.year + 50, now.month, now.day, now.hour, now.minute, now.second
        ):
            year -= 100
    if not 0 <= second <= 60:
        return None
    try:
        stamp = datetime(year, month, day, hour, minute, min(second, 59), tzinfo=UTC)
        stamp += timedelta(seconds=(second == 60) - parts[9])
    except (ValueError, OverflowError):
        return None
    return max(0.0, stamp.timestamp() - received_wall)


def retry_at(status, method, value, received_wall, monotonic_now, remaining, attempt, jitter=0.25):
    assert attempt >= 1 and jitter >= 0
    if status not in (429, 503) or method not in ("GET", "HEAD") or attempt >= 3:
        return None
    server_delay = delay_seconds(value, received_wall)
    wait = max(server_delay or 0.0, 2 ** (attempt - 1)) + jitter
    if wait >= remaining:
        return None
    return monotonic_now + wait


wall = REFERENCE.timestamp()
for value in ("Tue, 08 Sep 2026 12:00:07 GMT", "Tuesday, 08-Sep-26 12:00:07 GMT",
              "Tue Sep  8 12:00:07 2026"):
    assert delay_seconds(value, wall) == 7
assert delay_seconds("Sunday, 06-Nov-94 08:49:37 GMT", wall) == 0
assert delay_seconds("Monday, 08-Sep-70 12:00:00 GMT", wall) == (
    datetime(2070, 9, 8, 12, tzinfo=UTC).timestamp() - wall
)
assert delay_seconds("Sat, 31 Dec 2016 23:59:60 GMT",
                     datetime(2016, 12, 31, 23, 59, 59, tzinfo=UTC).timestamp()) == 1
for invalid in ("-1", "1.5", "later", "", "٣", "Tue, 31 Feb 2026 12:00:00 GMT"):
    assert delay_seconds(invalid, wall) is None
assert delay_seconds(" 0003\t", wall) == 3
assert retry_at(503, "GET", "9" * 5000, wall, 100, 10, 1) is None
assert retry_at(503, "GET", "3", wall, 100, 2, 1) is None
assert retry_at(503, "POST", "3", wall, 100, 10, 1) is None
assert retry_at(503, "GET", "3", wall, 100, 10, 3) is None
print("both delay forms, obsolete dates, year interpretation, and invalid values are checked")
print("an oversized wait, exhausted deadline, POST, or attempt cap stops this retry policy")

cases = {
    "/seconds": (429, "3"),
    "/date": (503, format_datetime(REFERENCE + timedelta(seconds=7), usegmt=True)),
    "/invalid": (503, "later"),
    "/missing": (503, None),
    "/redirect": (307, "3"),
    "/ready": (200, None),
}
visited = []


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        visited.append(self.path)
        status, value = cases.get(self.path, (404, None))
        self.send_response_only(status)
        self.send_header("Date", format_datetime(REFERENCE, usegmt=True))
        if value is not None:
            self.send_header("Retry-After", value)
        if status == 307:
            self.send_header("Location", "/ready")
        self.send_header("Content-Length", "0")
        self.end_headers()

    def log_message(self, *_):
        pass


with HTTPServer(("127.0.0.1", 0), Handler) as server:
    thread = Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05})
    thread.start()
    try:
        for route, expected in (("/seconds", 103.25), ("/date", 107.25),
                                ("/invalid", 101.25), ("/missing", 101.25), ("/redirect", None)):
            client = HTTPConnection(*server.server_address, timeout=2)
            try:
                client.request("GET", route)
                response = client.getresponse()
                response.read()
                planned = retry_at(response.status, "GET", response.getheader("Retry-After"),
                                   wall, 100, 10, 1)
                assert planned == expected
                print(route, "status", response.status, "planned monotonic time", planned)
            finally:
                client.close()
        assert "/ready" not in visited
    finally:
        server.shutdown()
        thread.join(timeout=2)
        assert not thread.is_alive()

same_delay = [retry_at(429, "GET", "3", wall, 100, 10, 1, jitter=0) for _ in range(4)]
spread = [retry_at(429, "GET", "3", wall, 100, 10, 1, jitter=j) for j in (0.1, 0.2, 0.3, 0.4)]
assert same_delay == [103] * 4
assert len(set(spread)) == 4 and min(spread) > 103
print("identical server delays synchronize plans; injected positive jitter spreads them:", spread)

解析检查覆盖列出的秒数、日期、旧格式、年份解释、闰秒和非法输入,但 email.utils 还接受 HTTP 日期语法之外的输入。因此这是宽松的调度演示,不是严格的 RFC 语法验证器;生产环境需要明确自己的接收和拒绝政策。有界政策会放弃超过剩余期限的等待、POST 重放,以及达到次数上限后的再次尝试。日期和时钟是明确设定的测试数据,并没有修改机器时间,也不代表运行当时的真实 UTC 时刻。

本地 429 返回三秒等待时,从注入的单调时间一百计算出 103.25 的计划时间;日期形式的 503 得到 107.25。缺失或非法字段使用已经声明的本地退避,重定向则不会被自动跟随。四个相同延迟产生四个 103 的计划,注入正向抖动后得到 103.1 到 103.4,展示的是可以验证的计划差异。

这些是响应与调度决定测试,不是实际按时重试的验收。真实时钟校准、网络分区、代理行为、生产抖动分布和业务操作去重仍未测试。落地时应观察最终发出的请求,不能把一组正确的计算时间,直接当成客户端已经在复杂网络中按时重试的证据。

延伸阅读

来源

  • RFC 9110,5.6.7 和 10.2.3 节定义 HTTP 日期和 Retry-After,已核验 2026-10-15。
  • RFC 6585,429 中可选的 Retry-After,已核验 2026-10-15。
  • Python 邮件工具,日期解析、UTC 偏移和时间戳格式化,已核验 2026-10-15。