超时异常处理与自定义拦截器

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 自定义拦截器捕获超时的实现流程

  1. 实现 Interceptor 接口,重写 intercept(Chain chain) 方法。
  2. 在方法内调用 chain.proceed(request) 获取响应。
  3. 包裹 try-catch 捕获 IOException 及其子类。
  4. 使用 instanceof 判断异常类型是否为 SocketTimeoutException(或其他超时相关异常)。
  5. 在 catch 块内记录详细的超时日志:请求 URL、超时阈值(从 OkHttp 配置中读取)、异常栈、网络状态等。
  6. 可选择将超时异常转换为自定义业务异常后再抛出,或直接抛出原始异常。
  7. 将拦截器添加至 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 超时拦截器工作流程

自定义超时拦截器工作流程 客户端发起请求 OkHttp拦截器链 自定义超时拦截器 intercept() try { chain.proceed() } 正常 Response SocketTimeoutException 输出超时详情日志 抛出异常或重试 成功 异常 提示:超时异常应在拦截器 中捕获,不建议在 UI 层判断 异常类型,保持层次清晰。

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 拦截器可能在不同线程并发执行,日志写入不应出现竞态(大多数日志框架已线程安全)。
  • 结合业务重试:捕获超时后不要立即在拦截器内自动重试(容易造成雪崩),应将异常透传至业务层,由重试策略决定是否重试(后续章节详述)。