超时异常处理与自定义拦截器
2.1 超时异常类型及原理
在 OkHttp 网络请求中,超时主要分为三类:连接超时、读取超时 和 写入超时。无论是哪一类超时,最终都会通过 Java 异常体系中的具体类型体现。Retrofit 底层默认使用 OkHttp,所有超时异常均继承自 java.io.IOException,但具体类型有明确区分:
| 异常类型 | 所属包 | 触发场景 | 典型原因 |
|---|---|---|---|
java.net.SocketTimeoutException |
java.net | 连接建立超时 / 数据读取超时 | 服务器未响应、网络丢包、防火墙拦截 |
java.net.ConnectException |
java.net | 连接被拒绝 | 服务器端口未开放、服务未启动 |
java.net.SocketException |
java.net | 底层 socket 异常 | 网络断开、连接被重置 |
java.util.concurrent.TimeoutException |
java.util.concurrent | 异步调用超时(RxJava/协程) | 调用方设置的超时时间耗尽 |
在实际项目中最常处理的超时异常是 SocketTimeoutException,它直接对应 HTTP 连接的 ConnectTimeout 和 ReadTimeout。而 TimeoutException 更多出现在异步框架层(如 RxJava 的 timeout 操作符)中,Retrofit 本身不直接抛出此异常,但上游调用方可能将其包装。
核心原则:自定义拦截器应优先捕获
SocketTimeoutException 及其父类 java.net.SocketException,同时保留对 IOException 的全面捕获以处理其他网络异常。
2.2 自定义拦截器捕获超时的实现流程
- 实现
Interceptor接口,重写intercept(Chain chain)方法。 - 在方法内调用
chain.proceed(request)获取响应。 - 包裹 try-catch 捕获
IOException及其子类。 - 使用
instanceof判断异常类型是否为SocketTimeoutException(或其他超时相关异常)。 - 在 catch 块内记录详细的超时日志:请求 URL、超时阈值(从 OkHttp 配置中读取)、异常栈、网络状态等。
- 可选择将超时异常转换为自定义业务异常后再抛出,或直接抛出原始异常。
- 将拦截器添加至 OkHttpClient,并构建 Retrofit 实例。
流程关键点:拦截器在 OkHttp 的“拦截器链”中运行,
chain.proceed() 可能抛出 IOException,但不会直接抛出运行时异常。因此 catch 块应使用 IOException 作为基类。
class TimeoutLoggingInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val connectTimeoutMs = chain.connectTimeoutMillis()
val readTimeoutMs = chain.readTimeoutMillis()
try {
return chain.proceed(request)
} catch (e: SocketTimeoutException) {
// 输出超时详情日志
logTimeoutDetails(request, e, connectTimeoutMs, readTimeoutMs)
throw e // 继续抛出,由上层处理或重试
} catch (e: IOException) {
// 非超时网络异常,可记录但不由本拦截器处理
throw e
}
}
private fun logTimeoutDetails(request: Request,
e: SocketTimeoutException,
connectTimeout: Long,
readTimeout: Long) {
val message = """
[超时拦截] 请求URL: ${request.url()}
[超时拦截] 连接超时配置: ${connectTimeout}ms 读取超时配置: ${readTimeout}ms
[超时拦截] 异常类型: ${e.javaClass.name}
[超时拦截] 异常消息: ${e.message}
[超时拦截] 本地时间: ${System.currentTimeMillis()}
""".trimIndent()
Log.w("TimeoutInterceptor", message)
}
}
2.3 日志输出超时详情的标准格式
为便于在服务器日志分析或本地调试时快速定位,日志输出应包含以下关键字段:
- 请求方法 + URL(含 query 参数)
- 当前 OkHttp 的 ConnectTimeout 和 ReadTimeout 值(通过
chain.connectTimeoutMillis()和chain.readTimeoutMillis()获取) - 异常全类名(区分 SocketTimeoutException 与其他 IOException)
- 异常消息(通常包含 “timeout”、“failed to connect” 等关键词)
- 触发时间戳(毫秒级)
- 当前网络状态(可选,可通过 ConnectivityManager 获取)
推荐使用结构化日志框架(如 Timber、slf4j)配合 tag 标记,便于过滤。以下为日志输出示例:
W/TimeoutInterceptor: [超时拦截] 请求URL: https://api.example.com/v1/users?page=2
W/TimeoutInterceptor: [超时拦截] 连接超时配置: 15000ms 读取超时配置: 30000ms
W/TimeoutInterceptor: [超时拦截] 异常类型: java.net.SocketTimeoutException
W/TimeoutInterceptor: [超时拦截] 异常消息: timeout
W/TimeoutInterceptor: [超时拦截] 本地时间: 1718306407123
注意:不要将超时日志打印在每一层拦截器中,否则会导致重复输出。应将自定义拦截器放在 OkHttp 拦截器链的最前面(通过
addInterceptor())以捕获所有网络异常,而不要使用 addNetworkInterceptor(),后者可能因重定向或缓存导致日志缺失。
2.4 超时拦截器工作流程
2.5 调试技巧
在开发环境验证超时拦截器效果时,可以使用以下方法构造“慢请求”触发超时:
- 将 OkHttp 的
ReadTimeout临时设置为极短值(如 1ms),观察日志是否按预期出现。 - 使用
curl -v --max-time 1 https://slow.example.com确认服务端确实不返回。 - 在 Android 真机调试时,打开“飞行模式”或使用网络代理模拟丢包,观察 ConnectTimeout 是否正确捕获。
- 查看日志输出中是否包含正确的 URL 和时间戳,排除日志重复或缺失。
极简测试代码片段(Kotlin):
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(1, TimeUnit.MILLISECONDS) // 故意设极小值
.readTimeout(1, TimeUnit.MILLISECONDS)
.addInterceptor(TimeoutLoggingInterceptor())
.build()
val retrofit = Retrofit.Builder()
.baseUrl("https://httpbin.org/")
.client(okHttpClient)
.build()
请求一个正常接口(如 /delay/3),立刻应看到超时拦截器输出日志。
2.6 常见错误与注意事项
| 错误场景 | 现象 | 正确做法 |
|---|---|---|
误用 addNetworkInterceptor() 添加超时拦截器 |
重定向或缓存请求时拦截器未被调用,超时日志丢失 | 使用 addInterceptor() 添加到应用拦截器 |
| 在拦截器中吞掉异常(catch 后不抛出) | 请求“成功”但无响应,后续代码崩溃于空 Response | 捕获后必须 throw e 或返回错误响应 |
| 同时捕获多种异常未区分级别 | 日志里 IOException 覆盖了 SocketTimeoutException | 优先捕获子类,再捕获父类 |
| 日志输出包含敏感信息(URL 中的 ) | 安全审计风险 | 只记录 path 和必要 param,过滤 auth 字段 |
性能敏感性:不要在超时日志中打印完整堆栈(
e.printStackTrace()),这会导致日志冗长且影响性能。仅打印异常消息和类型;如有需要,可在 Debug 环境临时开启堆栈打印。
2.7 性能与稳定性优化建议
- 日志级别:超时拦截器内使用
Log.w(警告级别)而非Log.e(错误级别),避免监控工具误报;生产环境可降级为Log.i或使用ConditionalLogger按需输出。 - 避免重复创建字符串:日志消息构建使用
StringBuilder或 Kotlin 的trimIndent()+多行模板,不要用字符串拼接。 - 注意线程安全:OkHttp 拦截器可能在不同线程并发执行,日志写入不应出现竞态(大多数日志框架已线程安全)。
- 结合业务重试:捕获超时后不要立即在拦截器内自动重试(容易造成雪崩),应将异常透传至业务层,由重试策略决定是否重试(后续章节详述)。