在网络请求这种充满不确定性的环境中,timeout(超时控制)和 retry(重试机制)是构建健壮 App 的必备工具。它们一个负责**“设置底线” ,一个负责“挽回局面”**。
1. Timeout:防止无限等待
timeout 操作符会在指定的时间内观察上游。如果这段时间内上游没有发出任何值(且没有完成),它会强制终止当前的订阅并抛出一个自定义错误。
核心参数
dueTime: 等待的最长时间(如.seconds(5))。scheduler: 计时器运行的线程(通常用DispatchQueue.main或global())。customError: 一个闭包,用于返回超时后抛出的错误。
适用场景
处理那些底层网络库可能卡死、或者服务器响应极其缓慢的情况,确保 UI 不会一直停留在加载状态。
Swift
URLSession.shared.dataTaskPublisher(for: url)
.timeout(.seconds(10), scheduler: DispatchQueue.main, customError: { URLError(.timedOut) })
.sink(receiveCompletion: { /* 处理可能的超时错误 */ }, receiveValue: { /* ... */ })
2. Retry:给失败一个机会
retry 会在收到上游的 .failure 信号时,重新订阅上游。
核心机制
- 重试次数:
retry(3)意味着如果失败,它会额外尝试 3 次(总共最多运行 4 次)。 - 触发条件: 只有收到
Failure才会触发。如果上游是正常Finished,它什么也不做。 - 副作用: 注意! 每次重试都会导致上游的所有副作用(如网络请求)重新执行一遍。
3. 实战技巧:组合使用与防御式设计
在真实场景中,我们很少直接用这两个操作符,通常需要配合其他技巧来防止“无效重试”。
A. 指数退避(Exponential Backoff)
直接重试通常没用(如果服务器宕机,瞬间重试只会增加服务器压力)。最好的做法是等一会儿再试。
Combine 中可以通过 delay 配合 retry 的变体逻辑(或自定义 Operator)来实现。
B. 只针对特定错误重试
你可能只想在网络断开时重试,而不想在“404 Not Found”时重试。
Swift
publisher
.tryCatch { error -> AnyPublisher<Data, Error> in
if let urlError = error as? URLError, urlError.code == .notConnectedToInternet {
return Fail(error: error)
.delay(for: 2, scheduler: DispatchQueue.global()) // 延迟 2 秒
.eraseToAnyPublisher()
}
throw error // 业务错误直接抛出,不触发重试
}
.retry(3)
C. Timeout 放在 Retry 之前还是之后?
- 放在 Retry 之前:单次请求如果超过 5 秒就判定为失败,并计入重试次数。
- 放在 Retry 之后:整个“请求+重试”的总过程如果超过 10 秒就彻底放弃。
4. 关键避坑指南
- Retry 的位置:
retry必须放在可能产生错误的算子之后。如果你把它放在map之前,而错误是由map产生的,retry将无法捕获并触发重试。 - 状态清理:如果你的 Publisher 涉及数据库写入,重试可能会导致数据重复插入。确保你的上游操作是**幂等(Idempotent)**的。
- 用户感知:在重试期间,最好在 UI 上显示“正在尝试重连...”,而不是让用户看着转圈圈。
总结比较
| 特性 | Timeout | Retry |
|---|---|---|
| 解决的问题 | 请求太慢 | 请求失败 |
| 成功标准 | 在时间内产生数据 | 最终能产生数据 |
| 错误影响 | 产生新的错误并终止 | 消耗错误并重新开始 |
| 性能考量 | 释放系统资源 | 增加网络和服务器负担 |