云端 Mac 上验收 iOS HTTP 重试:退避、限流与取消

云端 Mac 上验收 iOS HTTP 重试:退避、限流与取消

接口返回 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 超过客户端等待上限时,能截短后重试吗?

不能。截短会早于服务端要求再次请求。应返回延后处理状态,交给上层保存任务或提示用户,而不是在本轮自动重试中缩短等待。

如何让退避测试不需要真的等待?

把当前时间、随机值和等待执行器作为依赖注入。纯函数测试只比较计划延迟;执行器测试使用可控挂起点,取消后断言发送次数没有增加。

需要一台独享 Mac,完成这次打包?

按实际需要选择租期。整台物理 Mac 独享,macOS 图形界面与命令行完整可用。机型、节点及可用状态以控制台实时返回为准。

把步骤用在下一次归档。

先核对租期与配置,再开始这次构建。