如何在协程中优雅地处理网络请求失败并进行重试?

解读

面试官真正想考察的是:

  1. 你对 Kotlin 协程结构化并发与异常传播链的理解深度;
  2. 能否把“重试”做成可复用、可测试、不污染业务代码的通用组件;
  3. 对国内网络环境(弱网、运营商劫持、IPv6 双栈、网关 502/504 高发)是否有落地经验;
  4. 是否知道在 Android 生命周期内安全地取消重试,避免内存泄漏或 Crash;
  5. 对 Retrofit+OkHttp 现有重试机制与协程侧重试的边界分工是否清晰。

一句话:不是“写个循环”就行,而是让重试“像空气一样存在,又像水一样透明”。

知识点

  1. 协程异常传播:launch 用 CoroutineExceptionHandler,async 在 await() 时抛;结构化并发保证子协程异常向上传递。
  2. 重试策略:立即重试、固定间隔、指数退避(Exponential Backoff)、带抖动(Jitter)避免“雷群效应”。
  3. 取消敏感性:重试内部必须检查 isActive,每次 delay 前使用 ensureActive(),让外部 Job.cancel() 能秒级响应。
  4. 国内特色错误码:HTTP 522/524(CDN 超时)、运营商 302 广告、未知证书(-998)需单独识别,避免无脑重试。
  5. 分层职责:OkHttp 的 interceptor 做“快速重试”(3 次、毫秒级)、协程层做“业务重试”(3~5 次、秒级),两者互补。
  6. 日志与埋点:每次重试需记录 traceId、耗时、错误类型,方便后端按用户维度排查。
  7. 测试:使用 MockWebServer + KotlinTest 的“协程测试”扩展,验证重试次数、间隔、取消点。

答案

给面试官一个“可直接拷贝到生产模块”的模板,分三步讲:

第一步:定义“可配置”的重试策略

data class RetryConfig(
    val maxAttempts: Int = 3,
    val initialDelayMs: Long = 500,
    val maxDelayMs: Long = 10_000,
    val factor: Double = 2.0,
    val jitter: Boolean = true
)

第二步:写一个纯函数式重试器,不依赖 Android 框架,方便单元测试

suspend fun <T> retryWithPolicy(
    config: RetryConfig = RetryConfig(),
    block: suspend (attempt: Int) -> T
): T {
    var currentDelay = config.initialDelayMs
    repeat(config.maxAttempts) { attempt ->
        try {
            return block(attempt + 1)
        } catch (e: Exception) {
            // 国内常见不可重试异常,直接抛
            when {
                e is CancellationException -> throw e
                e is SSLHandshakeException -> throw e
                e is UnknownHostException && attempt == 0 -> throw e
            }
            if (attempt == config.maxAttempts - 1) throw e
            // 指数退避 + 抖动
            if (config.jitter) {
                currentDelay = (currentDelay * config.factor * (0.5 + Random.nextDouble(0.5))).toLong()
            } else {
                currentDelay = (currentDelay * config.factor).toLong()
            }
            currentDelay = currentDelay.coerceAtMost(config.maxDelayMs)
            delay(currentDelay)
        }
    }
    error("Unreachable")
}

第三步:在 ViewModel 里与协程作用域绑定,支持自动取消

class HomeViewModel : ViewModel() {

    private val _uiState = MutableStateFlow<UiState>(UiState.Idle)
    val uiState: StateFlow<UiState> = _uiState

    fun loadHomePage() = viewModelScope.launch {
        _uiState.value = UiState.Loading
        runCatching {
            retryWithPolicy {
                // 直接抛异常即可触发重试
                apiService.getHome()
            }
        }.onSuccess {
            _uiState.value = UiState.Success(it)
        }.onFailure {
            _uiState.value = UiState.Error(it.mapToMessage())
        }
    }
}

亮点补充(面试时主动说):

  • 用 ensureActive() 保证用户退出页面时重链立刻中断,不会浪费流量;
  • 对 401 令牌失效特殊处理:第一次直接 refreshToken,第二次再失败才抛到外层;
  • 与埋点平台对齐,每次重试带 x-request-id,方便后端按链路追踪;
  • 单元测试用 Turbine 测试 StateFlow,重试 3 次耗时 3.5 s,误差 < 100 ms。

拓展思考

  1. 如果重试策略需要动态下发(运营配置),可以把 RetryConfig 做成 Firebase RemoteConfig 或国内“友盟+”配置通道,热更新不重启进程。
  2. 对上传场景(如图片)应改用“分段重试”+“断点续传”,协程层只负责调度,真正的断点逻辑放到 OkHttp 的 RequestBody 里,避免重复压缩。
  3. 车载与 Wear 场景下网络切换频繁,可监听 ConnectivityManager.NetworkCallback,在网络恢复后自动重置重试计数器,而不是盲目继续 delay。
  4. 高并发接口(如秒杀)需引入“熔断”与“重试”分离:熔断走快速失败,重试只用在读多写少场景,防止放大流量把后端打挂。
  5. Kotlin 1.9 后支持“自动限频”协程调度器,可在重试器内部集成 Semaphore,控制全局重试 QPS,避免进程级“重试风暴”。