错误处理与重试:全局错误拦截器、协程的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 操作符
错误拦截器只是第一步。有些错误是暂时的,比如网络抖动、服务器短暂不可用。这时候直接放弃请求太可惜了。协程提供了 retry 和 retryWhen 操作符,可以让我们很方便地实现重试逻辑。
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。
六、知识体系总览
下面这张图展示了本章的核心逻辑:从请求发起,到错误拦截,再到重试决策,最后返回结果或失败。
七、总结
这一章我们聊了三件事:
- 全局错误拦截器:在 OkHttp 层面统一捕获异常,分类处理。这是网络层的守门员。
- retry 与 retryWhen:协程提供的重试操作符。retry 简单粗暴,retryWhen 灵活可控。
- 指数退避策略:重试间隔逐渐增大,避免给服务器造成压力。这是生产环境必备的优化。
说实话,错误处理这部分很容易被忽视。很多项目上线后才发现,原来网络层这么脆弱。我个人建议你在项目初期就把这套机制搭好,不然后面再改,牵一发而动全身。嗯,今天就到这里。
公众号:蓝海资料掘金营,微信deep3321