错误处理与重试:全局错误拦截器、协程的retry与retryWhen操作符、指数退避重试策略

网络请求,说白了就是跟不可靠的外部世界打交道。你永远不知道下一秒服务器是正常响应,还是给你扔回来一个 500,或者干脆超时没反应。我做了这么多年 Android,见过太多因为没处理好网络错误,导致 App 直接闪退或者数据展示异常的情况。嗯,这一章我们就来聊聊怎么优雅地处理这些错误,以及如何让请求在失败后自动重试。

一、为什么需要全局错误拦截器?

你想想看,一个 App 里可能有几十个网络请求。如果每个请求都自己写 try-catch,那代码得多冗余?而且很容易漏掉某些异常情况。我个人习惯的做法是:在 OkHttp 或 Retrofit 层面统一拦截错误,然后分发到统一的处理逻辑里。

这样做有几个好处:

  • 统一处理:所有网络错误都走同一个逻辑,比如弹出 Toast、记录日志、或者触发 token 刷新。
  • 减少重复代码:每个 Repository 或 ViewModel 里不用再写一堆 try-catch。
  • 便于维护:想改错误处理逻辑?只改一个地方就行。

核心思路:在 OkHttp 的 Interceptor 里捕获异常,或者在 Retrofit 的 CallAdapter 里做统一处理。我个人更推荐用 Interceptor,因为它能拿到原始的请求和响应信息。

二、实现全局错误拦截器

我们先写一个 OkHttp 的 Interceptor。这个拦截器会检查响应码,如果发现是 401(未授权),就自动触发 token 刷新逻辑。如果是其他错误,就包装成我们自定义的异常类。

class ErrorInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        return try {
            val response = chain.proceed(request)
            when (response.code) {
                401 -> throw TokenExpiredException("Token 已过期,请重新登录")
                403 -> throw ForbiddenException("没有权限访问")
                404 -> throw NotFoundException("资源不存在")
                in 500..599 -> throw ServerErrorException("服务器内部错误: ${response.code}")
                else -> response
            }
        } catch (e: SocketTimeoutException) {
            throw NetworkTimeoutException("请求超时,请检查网络")
        } catch (e: UnknownHostException) {
            throw NetworkUnavailableException("网络不可用")
        }
    }
}

然后在 Retrofit 构建时加上这个拦截器:

val okHttpClient = OkHttpClient.Builder()
    .addInterceptor(ErrorInterceptor())
    .build()

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .client(okHttpClient)
    .addConverterFactory(GsonConverterFactory.create())
    .build()

小提示:如果你用的是 Kotlin,建议把自定义异常类定义成 sealed class,这样在 when 表达式里可以穷举所有情况,编译器会帮你检查有没有遗漏。

三、协程中的 retry 与 retryWhen 操作符

错误拦截器只是第一步。有些错误是暂时的,比如网络抖动、服务器短暂不可用。这时候直接放弃请求太可惜了。协程提供了 retryretryWhen 操作符,可以让我们很方便地实现重试逻辑。

retry 很简单,就是指定重试次数:

viewModelScope.launch {
    val result = flow {
        emit(apiService.getUserInfo())
    }.retry(3)  // 最多重试 3 次
     .catch { e ->
         emit(Result.failure(e))
     }
     .collect { ... }
}

retry 有个问题:它不管什么错误都会重试。如果遇到 401 这种需要用户重新登录的错误,重试多少次都没用。这时候就该 retryWhen 出场了。

retryWhen 可以让你根据异常类型和重试次数来决定是否继续重试:

viewModelScope.launch {
    flow {
        emit(apiService.getUserInfo())
    }.retryWhen { cause, attempt ->
        // cause: 抛出的异常
        // attempt: 当前是第几次重试(从 0 开始)
        when (cause) {
            is NetworkTimeoutException -> {
                // 网络超时,可以重试
                delay(1000)  // 重试前等 1 秒
                true
            }
            is ServerErrorException -> {
                // 服务器错误,最多重试 2 次
                attempt < 2
            }
            else -> {
                // 其他错误不重试
                false
            }
        }
    }.catch { e ->
        // 所有重试都失败了,到这里处理
        emit(Result.failure(e))
    }.collect { ... }
}

注意retryWhen 里的 delay 是挂起函数,不会阻塞线程。但如果你在重试逻辑里做了耗时操作,记得用 withContext(Dispatchers.IO) 切换线程。

四、指数退避重试策略

刚才的 retryWhen 里我们用了固定的 1 秒延迟。但你想过没有?如果服务器正在经历高峰期,你每隔 1 秒就重试一次,反而会加重服务器负担。更合理的做法是:每次重试的间隔逐渐增大。

这就是指数退避策略。简单来说:第一次重试等 1 秒,第二次等 2 秒,第三次等 4 秒,第四次等 8 秒……每次翻倍。

实现起来也很简单:

fun retryWithExponentialBackoff(
    maxRetries: Int = 3,
    baseDelay: Long = 1000L,  // 基础延迟 1 秒
    maxDelay: Long = 10000L   // 最大延迟 10 秒
): Flow<T> {
    return this.retryWhen { cause, attempt ->
        if (attempt >= maxRetries) {
            return@retryWhen false
        }
        // 计算延迟时间:baseDelay * 2^attempt
        val delayTime = minOf(baseDelay * (1L shl attempt), maxDelay)
        delay(delayTime)
        true
    }
}

然后你就可以这样用:

viewModelScope.launch {
    flow {
        emit(apiService.getUserInfo())
    }.retryWithExponentialBackoff(maxRetries = 3, baseDelay = 2000L)
     .catch { ... }
     .collect { ... }
}

我在项目中遇到过:有一次线上反馈说某些用户登录后一直加载不出来。排查发现是 token 刷新接口在高峰期偶尔超时,但我们的重试策略是固定 500 毫秒,结果连续重试 5 次都失败了。改成指数退避后,第一次重试等 1 秒,第二次等 2 秒,第三次等 4 秒,大部分用户都能在第三次重试时成功。嗯,这就是指数退避的价值。

五、结合全局拦截器与重试策略

现在我们把前面讲的东西串起来。全局拦截器负责捕获异常并分类,重试策略负责在合适的时机重新发起请求。两者配合,就能构建一个健壮的网络层。

我建议的架构是这样的:

// 1. 定义统一的错误类型
sealed class NetworkError : Throwable() {
    class TokenExpired(msg: String) : NetworkError(msg)
    class ServerError(msg: String, val code: Int) : NetworkError(msg)
    class Timeout(msg: String) : NetworkError(msg)
    class NetworkUnavailable(msg: String) : NetworkError(msg)
}

// 2. 全局拦截器(前面已经写了)

// 3. 在 Repository 层使用重试
class UserRepository(private val api: UserApi) {
    suspend fun getUserInfo(): Result<UserInfo> = runCatching {
        flow {
            emit(api.getUserInfo())
        }.retryWhen { cause, attempt ->
            when (cause) {
                is NetworkError.Timeout -> {
                    delay(minOf(1000L * (1L shl attempt), 10000L))
                    attempt < 3
                }
                is NetworkError.ServerError -> {
                    // 5xx 错误才重试,4xx 不重试
                    cause.code in 500..599 && attempt < 2
                }
                else -> false
            }
        }.first()
    }
}

避坑指南:我曾经在重试逻辑里忘记处理 CancellationException。如果用户在重试过程中退出了页面,协程会被取消,但 retryWhen 里的 delay 会抛出 CancellationException。如果你用 runCatching 包裹了整个流程,这个异常会被捕获,导致协程无法正常取消。解决办法是在 retryWhen 里检查当前协程是否活跃:if (!isActive) return@retryWhen false

六、知识体系总览

下面这张图展示了本章的核心逻辑:从请求发起,到错误拦截,再到重试决策,最后返回结果或失败。

错误处理与重试核心流程 发起网络请求 全局错误拦截器(分类异常) 是否成功? 返回成功结果 进入重试决策 retryWhen 决策 检查异常类型 + 重试次数 继续重试 放弃重试 指数退避延迟 baseDelay × 2^attempt 重新发起请求

七、总结

这一章我们聊了三件事:

  1. 全局错误拦截器:在 OkHttp 层面统一捕获异常,分类处理。这是网络层的守门员。
  2. retry 与 retryWhen:协程提供的重试操作符。retry 简单粗暴,retryWhen 灵活可控。
  3. 指数退避策略:重试间隔逐渐增大,避免给服务器造成压力。这是生产环境必备的优化。

说实话,错误处理这部分很容易被忽视。很多项目上线后才发现,原来网络层这么脆弱。我个人建议你在项目初期就把这套机制搭好,不然后面再改,牵一发而动全身。嗯,今天就到这里。


公众号:蓝海资料掘金营,微信deep3321