接口返回 429,客户端却立刻再发三次;用户已经退出,旧请求仍在等待后重发。这类故障不是把超时调大就能解决。在云端 Mac 上验收 iOS 网络层,应先把重试拆成三个边界:响应如何解释、何时允许再发、取消后谁负责阻止发送。测试不必真的等几十秒。
先定义允许重试的请求
从只读 GET 开始,示例只处理 429 和 503。其他状态与传输错误另设策略,不要把所有失败塞进同一个循环。首次发送不计入重试次数;最多重试三次,意味着最多发送四次。
写请求不能仅凭方法名决定安全性。只有服务端支持幂等协议、同一业务操作复用同一幂等键,并且请求体能够重新读取时,才考虑自动重发。流式上传还要验证流能否重新创建。
超时只说明客户端没拿到结果,不说明服务端没有完成写入。取消本地任务也不会撤销已经抵达服务端的操作。
每个逻辑请求独立保存预算。不要在每次进入网络函数时重置计数,否则外层和内层各重试三次,会放大实际请求量。
把响应头转成明确的等待约束
Retry-After 有两类值:非负整数秒,以及 HTTP 日期。整数只接受 ASCII 数字,拒绝负数、小数和溢出;日期解析固定英文区域与 GMT,不能依赖节点当前语言。下面展示常见的 IMF-fixdate 格式:
import Foundation
func serverDelay(_ raw: String?, now: Date) -> TimeInterval? {
guard let raw else { return nil }
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty,
text.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }) {
guard let seconds = UInt64(text) else { return nil }
return TimeInterval(seconds)
}
let parser = DateFormatter()
parser.locale = Locale(identifier: "en_US_POSIX")
parser.timeZone = TimeZone(secondsFromGMT: 0)
parser.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
parser.isLenient = false
guard let date = parser.date(from: text),
parser.string(from: date) == text else {
return nil
}
return max(0, date.timeIntervalSince(now))
}
这是严格的常见格式解析器,不是完整的历史 HTTP 日期兼容实现。若接口契约要求接受旧日期格式,需要单独补解析分支与夹具。无效值在本例中回退到本地退避,并记录解析失败原因。
固定 now 后,分别检查过去、现在和未来的日期。运行中等待使用持续时钟;墙上时钟只负责把日期转换为剩余秒数,避免系统校时改变已经开始的等待。
用纯函数计算退避,不在里面睡眠
保存以下内容为 retry-check.swift,接在前面的解析函数后。retryIndex 从零开始;基础延迟依次为 1、2、4 秒,使用等比例抖动,随机输入由调用者提供。
enum Decision: Equatable {
case stop
case deferred
case retry(TimeInterval)
}
func decide(
status: Int,
replayable: Bool,
retryIndex: Int,
server: TimeInterval?,
unit: Double
) -> Decision {
guard replayable,
[429, 503].contains(status),
(0..<3).contains(retryIndex),
unit.isFinite,
(0...1).contains(unit) else {
return .stop
}
let base = pow(2.0, Double(retryIndex))
let local = base * (0.5 + 0.5 * unit)
if let server, !server.isFinite || server < 0 {
return .stop
}
let delay = max(local, server ?? 0)
guard delay <= 30 else { return .deferred }
return .retry(delay)
}
precondition(decide(
status: 503, replayable: true,
retryIndex: 0, server: nil, unit: 0
) == .retry(0.5))
precondition(decide(
status: 429, replayable: true,
retryIndex: 1, server: 10, unit: 1
) == .retry(10))
precondition(decide(
status: 429, replayable: true,
retryIndex: 0, server: 60, unit: 0
) == .deferred)
precondition(decide(
status: 503, replayable: true,
retryIndex: 3, server: nil, unit: 1
) == .stop)
precondition(decide(
status: 503, replayable: false,
retryIndex: 0, server: nil, unit: 1
) == .stop)
在已配置 Swift 工具链的节点执行:
swift retry-check.swift
正常退出表示断言通过。不要加优化选项禁用检查。这里的 30 秒是示例自动等待上限,不是协议规定;服务端要求更久时返回延后状态,不能截成 30 秒提前重发。
验收取消边界,而不只是延迟数字
执行器只接收决策,再调用可注入的等待函数。生产环境可用可取消的 Task.sleep;测试环境用可控挂起点,并提供“已经进入等待”的通知,避免靠短暂睡眠猜测任务进度。
至少验证三个顺序:
- 等待前取消:发送次数不增加。
- 等待中取消:等待抛出的取消错误向上传播,不进入普通失败重试。
- 等待刚结束时取消:发送前再次检查任务取消状态和会话代次。
会话代次是登录状态每次切换后递增的标识。旧请求即使收到成功响应,也必须先比较代次再更新界面或缓存。检查取消与实际发出请求之间仍有竞态,因此不能宣称取消保证服务端完全收不到请求。
把失败证据变成合并检查项
为解析器补齐空值、空白、负数、小数、超大整数和日期边界;为调度器覆盖重试索引零到三、随机端点零与一,以及服务端等待恰好达到和超过上限的情况。
集成测试使用脚本化传输替身,按顺序返回 503、429、200,并核对每次发送前的计划延迟。再加入取消、不可重放正文和预算耗尽场景。不要用真实公共接口制造限流,也不要让测试环境真正等待响应头指定的时长。
日志只保留逻辑请求标识、状态码、重试索引、计划延迟与终止原因,不记录认证头或正文。最终验收看四件事:没有过早重发、没有超预算发送、取消不会被当成普通错误、不可重放请求没有进入循环。做到这些,再讨论退避参数是否需要调整。
常见问题
收到 429 或 503 就应该自动重试吗?
不应该。还要检查请求能否安全重放、是否超出重试预算以及服务端等待要求。写请求只有具备服务端认可的幂等协议和可重放请求体时,才适合自动重试。
Retry-After 超过客户端等待上限时,能截短后重试吗?
不能。截短会早于服务端要求再次请求。应返回延后处理状态,交给上层保存任务或提示用户,而不是在本轮自动重试中缩短等待。
如何让退避测试不需要真的等待?
把当前时间、随机值和等待执行器作为依赖注入。纯函数测试只比较计划延迟;执行器测试使用可控挂起点,取消后断言发送次数没有增加。