第八篇:Ktor 异常体系:断网、Timeout、HTTP、JSON 与业务错误如何统一成 AppError

前面几篇,我已经把 Ktor 网络请求的主链路逐渐搭起来了:

复制代码
ApiService
    ↓
NetworkClient
    ↓
HttpClient
    ↓
HttpResponse
    ↓
ApiResponse<T>
    ↓
检查 code
    ↓
data
    ↓
T

成功流程已经比较清楚。

但是正式项目真正麻烦的地方,往往不是:

复制代码
请求成功怎么办?

而是:

复制代码
请求失败怎么办?

例如一次请求可能遇到:

复制代码
请求前已经明确断网

请求过程中网络突然断开

DNS 解析失败

连接服务器失败

Connect Timeout

Request Timeout

Socket Timeout

HTTP 401

HTTP 404

HTTP 500

JSON 解析失败

业务 code != 0

未知异常

如果这些错误直接一路抛到 ViewModel:

复制代码
ViewModel
↓
catch Throwable
↓
判断各种 Ktor / Engine / Serialization 异常

业务层很快就会和底层网络框架绑定。

所以这一篇要解决的是:

如何把不同阶段产生的网络错误,统一转换成项目自己的 AppError

最终希望形成:

复制代码
请求前网络检查
        │
        └── 明确断网
              ↓
        AppError.Network


真正网络请求
        ↓
    Throwable
        ↓
ExceptionMapper
        ↓
    AppError

业务层最终只关心:

复制代码
Network
Timeout
Unauthorized
Forbidden
NotFound
Server
Parse
Business
Unknown

而不需要了解底层具体异常类型。


一、为什么不能直接 catch Exception?

最简单的时候,我们可能会写:

复制代码
try {

    val user =
        networkClient.get<User>(
            path = "users/1001",
        )

} catch (e: Exception) {

    // 请求失败
}

功能上当然可以。

但问题在于:

复制代码
Exception

到底代表什么?

可能是:

复制代码
手机已经断网

请求过程中断网

服务器连接失败

Timeout

401

500

JSON 解析失败

code != 0

如果全部统一显示:

复制代码
请求失败

显然不够。

不同错误通常意味着:

复制代码
不同日志
不同重试策略
不同业务动作
不同 UI 提示

所以需要先把错误分层。


二、一次请求其实有多个失败阶段

完整一点,可以把一次请求理解成:

复制代码
业务发起请求
      ↓
请求前检查
      ↓
网络执行
      ↓
HTTP Response
      ↓
Response Body
      ↓
JSON 解析
      ↓
ApiResponse<T>
      ↓
业务 code
      ↓
data

因此失败可能发生在:

复制代码
① 请求真正发出之前

② 网络连接 / 传输阶段

③ HTTP 协议阶段

④ JSON 解析阶段

⑤ 业务协议阶段

结构:

复制代码
Request
   │
   ├── 请求前发现断网
   │
   ↓
Network / Engine
   │
   ├── 网络突然断开
   ├── DNS / Connect 失败
   ├── Timeout
   ↓
HTTP Response
   │
   ├── 401
   ├── 404
   ├── 500
   ↓
JSON
   │
   ├── 数据格式错误
   ↓
ApiResponse<T>
   │
   ├── code != 0
   ↓
data

所以我们首先要建立:

错误发生在哪一层?

而不是看到所有问题都叫:

复制代码
网络异常

三、第一层:断网最好先做"请求前快速失败"

tips:

补充篇 8.1:《Ktor/KMP 断网处理:为什么请求前要先判断网络状态?》

以前 Android 项目中,我们经常会在 OkHttp Interceptor 中做:

复制代码
请求准备发送
↓
检查当前网络状态
↓
没有网络
↓
直接抛 NoNetworkException
↓
不执行 chain.proceed()

也就是说:

如果已经明确知道设备没有互联网连接,那么根本没必要继续进入真正的 HTTP 网络请求。

在 Ktor/KMP 中也可以保持同样的思想。

例如我们可以抽象:

Kotlin 复制代码
interface NetworkConnectivityProvider {

    fun isConnected(): Boolean
}

然后在统一请求入口:

Kotlin 复制代码
class NetworkClient(
    private val client: HttpClient,
    private val connectivityProvider:
        NetworkConnectivityProvider,
) {

    suspend inline fun <reified T> get(
        path: String,
    ): T {

        if (!connectivityProvider.isConnected()) {
            throw NoNetworkException()
        }

        return client
            .get(path)
            .body()
    }
}

执行流程:

复制代码
ApiService
    ↓
NetworkClient
    ↓
NetworkConnectivityProvider
    ↓
有可用网络吗?
   /       \
 否         是
 ↓          ↓
直接失败    HttpClient
            ↓
           Engine
            ↓
         HTTP 请求

如果明确没网:

复制代码
HttpClient
Engine
DNS
Connect
Retry
Timeout

这些后续流程都不需要进入。

因此请求前检查最大的价值不是单纯:

复制代码
"捕获断网异常"

而是:

Fail Fast:已知请求不可能成功,就不要继续做无意义的网络工作。

它可以减少:

复制代码
无意义的网络栈执行
无意义的连接尝试
不必要的 Timeout 等待
不必要的 Retry
日志噪音

同时用户也能更快得到反馈。

Android 平台可以通过 ConnectivityManager / NetworkCapabilities 判断网络状态。

需要注意:

**NET_CAPABILITY_INTERNET **只表示网络被配置为可访问互联网,并不保证当前真的可以访问公网;

NET_CAPABILITY_VALIDATED 表示系统最近成功验证过公网连通性,更接近我们需要的"互联网可用"语义。即便已经 VALIDATED,网络仍可能随后突然失效。

所以:

复制代码
请求前断网检查

是一道很有价值的:

复制代码
第一道防线

但它不能成为唯一防线

这个问题后面还会继续讲。


四、第二层:真实网络请求仍然可能失败

假设请求前检查:

复制代码
NetworkConnectivityProvider
↓
有网

是不是意味着接下来一定成功?

不是。

因为可能:

复制代码
10:00:00.000

检查网络
↓
有网


10:00:00.020

开始请求


10:00:00.050

Wi-Fi 断开

也可能:

复制代码
系统认为互联网可用
↓
但目标服务器当前不可达

或者:

复制代码
DNS 解析失败
连接被拒绝
连接过程中网络切换
Socket 被关闭

所以真正请求执行以后:

复制代码
HttpClient
↓
Engine
↓
网络系统

仍然必须处理底层异常。

这就是:

复制代码
第二道防线

整个模型应该是:

复制代码
                Request
                   ↓
        NetworkConnectivityProvider
                   ↓
             请求前检查
              /       \
            没网       有网
             ↓          ↓
      AppError.Network  HttpClient
                        ↓
                       Engine
                        ↓
                真正执行网络请求
                        ↓
              网络仍可能突然失败
                        ↓
                    Throwable
                        ↓
                ExceptionMapper
                        ↓
                AppError.Network

所以:

请求前检查负责快速失败,真实网络异常处理负责最终兜底。

二者不是二选一。


五、AppError.Network 可以来自两个入口

这点非常重要。

业务层最终看到:

复制代码
AppError.Network

它可能来自:

第一种

复制代码
请求前检查
↓
明确没有互联网连接
↓
直接 AppError.Network

请求甚至没有真正进入 Engine。

第二种

复制代码
请求前检查正常
↓
开始 HTTP
↓
网络过程中出现连接异常
↓
Throwable
↓
ExceptionMapper
↓
AppError.Network

对于 ViewModel 来说:

复制代码
网络就是不可用

大多数情况下没有必要知道:

复制代码
到底是请求前发现的
还是请求过程中断掉的

所以两个入口可以最终统一到:

复制代码
AppError.Network

这就是错误抽象的价值。


六、第三类:Timeout

Timeout 建议单独处理。

因为:

复制代码
Network

和:

复制代码
Timeout

虽然都属于网络请求失败,但业务含义并不完全一样。

例如:

复制代码
Network
↓
当前网络不可用 / 网络连接失败

而:

复制代码
Timeout
↓
请求已经进入网络流程
但某个阶段等待时间超过限制

Ktor 的 HttpTimeout 主要提供三种超时:

复制代码
Request Timeout
Connect Timeout
Socket Timeout

官方定义分别是:

复制代码
Request Timeout
↓
整个 HTTP Call 从发送到接收完成所允许的时间


Connect Timeout
↓
与服务器建立连接允许的最大时间


Socket Timeout
↓
数据交换过程中两个数据包之间允许的最大空闲时间

Ktor 对应可能抛出:

复制代码
HttpRequestTimeoutException

ConnectTimeoutException

SocketTimeoutException

因此:

复制代码
sealed interface AppError {

    data object Network : AppError

    data object Timeout : AppError
}

是一个比较合理的基础设计。


七、第四类:HTTP Error

如果已经拿到了:

复制代码
HTTP Response

说明 HTTP 通信至少已经走到了服务端响应阶段。

但是状态码可能是:

复制代码
401 Unauthorized

403 Forbidden

404 Not Found

500 Internal Server Error

502 Bad Gateway

503 Service Unavailable

这些属于:

复制代码
HTTP 协议层错误

它和:

复制代码
请求前断网

完全不是一个层级。

也和:

复制代码
ApiResponse.code != 0

不是一个层级。


八、expectSuccess = true 到底负责哪一层?

例如:

复制代码
HttpClient {
    expectSuccess = true
}

它处理的是:

复制代码
HTTP Status

也就是说:

复制代码
401
404
500

这类状态不会继续按照普通成功 Response 处理,而会进入 Ktor 的异常机制。

可以理解成:

复制代码
HttpClient
↓
收到 HTTP Response
↓
检查 Status
↓
非成功状态
↓
抛出 HTTP 相关异常

但是:

复制代码
{
  "code": 10001,
  "msg": "token expired",
  "data": null
}

这里的:

复制代码
code = 10001

属于你自己项目的:

复制代码
业务协议

Ktor 不知道它意味着什么。

所以:

复制代码
expectSuccess
↓
处理 HTTP Status


ApiResponse.code
↓
处理业务状态

不要混淆。


九、HTTP Error 可以进一步转换

例如:

复制代码
sealed interface AppError {

    data object Network : AppError

    data object Timeout : AppError

    data object Unauthorized : AppError

    data object Forbidden : AppError

    data object NotFound : AppError

    data class Server(
        val statusCode: Int,
        val message: String? = null,
    ) : AppError
}

可以建立映射:

复制代码
401
↓
Unauthorized


403
↓
Forbidden


404
↓
NotFound


500 / 502 / 503
↓
Server

以后业务层:

Kotlin 复制代码
when (error) {

    AppError.Unauthorized -> {
        // 登录状态处理
    }

    AppError.NotFound -> {
        // 数据不存在
    }

    is AppError.Server -> {
        // 服务端异常
    }

    else -> Unit
}

业务层就不需要认识 Ktor HTTP 异常类。


十、第五类:JSON 解析失败

假设:

复制代码
HTTP 200

说明 HTTP 层没问题。

但是客户端期望:

复制代码
{
  "code": 0,
  "msg": "",
  "data": {
    "id": 1001
  }
}

服务器却返回:

复制代码
{
  "code": "abc",
  "msg": "",
  "data": {}
}

客户端模型:

复制代码
@Serializable
data class ApiResponse<T>(
    val code: Int,
    val msg: String,
    val data: T,
)

期望:

复制代码
code = Int

实际:

复制代码
code = String

于是:

复制代码
ContentNegotiation
↓
kotlinx.serialization
↓
反序列化失败

这属于:

复制代码
Parse / Serialization Error

而不是:

复制代码
Network

也不是:

复制代码
HTTP Server Error

所以可以:

复制代码
data object Parse : AppError

十一、为什么 ParseError 要单独存在?

因为它通常意味着:

复制代码
后端协议发生变化

客户端 DTO 写错

返回数据结构异常

服务端返回了非预期内容

例如:

复制代码
后端:
userId = String

客户端:
userId = Long

这是:

复制代码
客户端和服务端的数据协议不匹配

而不是:

复制代码
用户网络不好

所以日志里必须能够明确看到:

复制代码
Parse

方便开发定位。


十二、第六类:业务错误 code != 0

上一篇我们已经讲过:

复制代码
HTTP 200

Body:

复制代码
{
  "code": 20001,
  "msg": "订单不存在",
  "data": null
}

这里:

复制代码
网络成功
↓
HTTP 成功
↓
JSON 成功
↓
业务失败

所以属于:

复制代码
Business Error

可以定义:

复制代码
data class Business(
    val code: Int,
    val message: String,
) : AppError

这样:

复制代码
HTTP 404

和:

复制代码
HTTP 200 + code = 20001

就不会混到一起。


十三、第七类:Unknown Error

无论我们分类得多完善,都会存在:

复制代码
当前没有识别的 Throwable

所以最终需要:

复制代码
data class Unknown(
    val cause: Throwable? = null,
) : AppError

作为兜底。

但:

复制代码
Unknown

应该是:

复制代码
最后兜底

而不是所有不好判断的错误全部塞进去。

否则错误分类也就失去了意义。


十四、一个基础 AppError 可以这样设计

Kotlin 复制代码
sealed interface AppError {

    data object Network : AppError

    data object Timeout : AppError

    data object Unauthorized : AppError

    data object Forbidden : AppError

    data object NotFound : AppError

    data class Server(
        val statusCode: Int,
        val message: String? = null,
    ) : AppError

    data object Parse : AppError

    data class Business(
        val code: Int,
        val message: String,
    ) : AppError

    data class Unknown(
        val cause: Throwable? = null,
    ) : AppError
}

这已经覆盖大多数普通业务项目:

复制代码
Network
Timeout
HTTP
Parse
Business
Unknown

十五、为什么 AppError 不一定直接继承 Exception?

这里可以把两个概念拆开。

复制代码
Throwable

代表:

程序执行过程中真正抛出来的异常。

而:

复制代码
AppError

代表:

项目最终想表达的错误语义。

比如底层:

复制代码
ConnectException
UnknownHostException
平台 Socket Exception

最终都可能是:

复制代码
AppError.Network

所以结构可以是:

复制代码
底层 Throwable
↓
ExceptionMapper
↓
AppError

这比把底层异常一比一复制到业务层更加稳定。


十六、ExceptionMapper 是干什么的?

可以定义:

Kotlin 复制代码
interface ExceptionMapper {

    fun map(
        throwable: Throwable,
    ): AppError
}

职责只有一个:

把底层 Throwable 翻译成项目能够理解的 AppError。

例如:

复制代码
Ktor Timeout Exception
↓
AppError.Timeout


HTTP 401 Exception
↓
AppError.Unauthorized


SerializationException
↓
AppError.Parse


ApiException(code = 20001)
↓
AppError.Business

十七、为什么不直接在每个 get/post 里判断?

如果:

复制代码
get
post
put
delete
upload
download

都写:

复制代码
try {
    ...
} catch (e: Throwable) {
    when (e) {
        ...
    }
}

就会产生大量重复。

所以更合理的是:

复制代码
NetworkClient
↓
统一捕获

ExceptionMapper
↓
统一解释

职责:

复制代码
NetworkClient
↓
请求执行边界


ExceptionMapper
↓
异常翻译边界

十八、HTTP Error 怎么映射?

概念上:

复制代码
when (statusCode) {

    401 -> {
        AppError.Unauthorized
    }

    403 -> {
        AppError.Forbidden
    }

    404 -> {
        AppError.NotFound
    }

    in 500..599 -> {
        AppError.Server(
            statusCode = statusCode,
        )
    }

    else -> {
        AppError.Unknown()
    }
}

这应该属于:

复制代码
统一网络层

而不是散落到:

复制代码
UserApi
OrderApi
RobotApi

里面。


十九、业务 code 怎么映射?

上一篇已经有:

Kotlin 复制代码
if (
    response.code != ApiCode.SUCCESS
) {

    throw ApiException(
        code = response.code,
        message = response.msg,
    )
}

然后:

复制代码
ApiException
↓
ExceptionMapper
↓
AppError.Business

例如:

Kotlin 复制代码
is ApiException -> {

    AppError.Business(
        code = throwable.code,
        message = throwable.message,
    )
}

于是业务协议错误也进入统一体系。


二十、有些业务 code 可以继续上升成通用错误

例如后端规定:

复制代码
10001
↓
Token 失效

那么:

复制代码
when (throwable.code) {

    10001 -> {
        AppError.Unauthorized
    }

    else -> {
        AppError.Business(
            code = throwable.code,
            message = throwable.message,
        )
    }
}

于是:

复制代码
HTTP 401

和:

复制代码
HTTP 200
+
code = 10001

最终都可能成为:

复制代码
AppError.Unauthorized

业务层只关心:

复制代码
当前认证状态失效

而不用关心它究竟来源于哪一种后端实现。


二十一、网络层不要决定 UI 行为

例如:

复制代码
AppError.Unauthorized

NetworkClient 不应该:

复制代码
跳转登录页

也不应该:

复制代码
弹 Toast

网络层只负责:

复制代码
告诉上层:
Unauthorized

至于:

复制代码
退出登录
刷新 Token
跳转登录
显示 Dialog

由更上层决定。

尤其 KMP 的:

复制代码
commonMain

更加不应该依赖 Android:

复制代码
Context
Toast
Activity

二十二、Timeout 为什么应该单独映射?

虽然 Timeout 最终也是请求失败,但它和:

复制代码
明确断网

不是同一类状态。

例如:

复制代码
请求前:
已经没网
↓
根本不发送
↓
Network

而:

复制代码
有网
↓
请求正常进入 Engine
↓
连接建立时间超过限制
↓
Connect Timeout

或者:

复制代码
服务器连接成功
↓
长时间没有数据
↓
Socket Timeout

这些更适合:

复制代码
AppError.Timeout

因为后续:

复制代码
重试策略
日志
用户提示

都有可能不同。


二十三、断网、网络失败与 Timeout 到底是什么关系?

tips:

补充篇 8.1:《Ktor/KMP 断网处理:为什么请求前要先判断网络状态?》

以前很容易形成一种简单理解:

复制代码
断网
↓
请求一直等
↓
Timeout

这并不准确。

实际上应该分成三种情况。


第一种:请求前已经明确断网

复制代码
NetworkConnectivityProvider
↓
没有互联网连接
↓
直接 AppError.Network

此时:

复制代码
HttpClient
↓
没有执行

也就不会进入:

复制代码
Connect Timeout
Request Timeout
Socket Timeout

所以这种情况下:

断网在 Timeout 之前就已经被快速失败处理掉了。


第二种:请求前正常,但网络执行阶段失败

例如:

复制代码
检查时有网
↓
开始请求
↓
Wi-Fi 突然断开

或者:

复制代码
DNS 解析失败
连接被拒绝
网络路由不可达
Socket 连接异常

底层可能很快直接抛出:

复制代码
连接类异常

然后:

复制代码
ExceptionMapper
↓
AppError.Network

这种情况下也不一定需要等到 Timeout。


第三种:请求进入等待状态,并超过时间限制

例如:

复制代码
尝试连接服务器
↓
长时间建立不了连接
↓
ConnectTimeoutException

或者:

复制代码
连接成功
↓
服务器长时间不返回数据
↓
SocketTimeoutException

或者:

复制代码
整个请求超过规定时间
↓
HttpRequestTimeoutException

这才真正属于:

复制代码
AppError.Timeout

Ktor 的 HttpTimeout 官方正是按照 request / connect / socket 三类时间范围管理超时。不同 Engine 对具体 Timeout 类型的支持也并不完全一致,例如当前文档中 Darwin 和 JavaScript Engine 对 connect/socket timeout 的支持就有所区别。

因此,正确关系应该是:

复制代码
                     Request
                        ↓
                请求前网络检查
                 /            \
              没网             有网
               ↓                ↓
       AppError.Network       Engine
                                ↓
                    ┌───────────┴───────────┐
                    ↓                       ↓
              网络立即失败               等待超时
                    ↓                       ↓
           AppError.Network         AppError.Timeout

所以:

断网不等于 Timeout,Timeout 也不等于断网。

二者可能最终都是"请求没有成功",但发生阶段和错误语义不同。


二十四、Retry 和异常体系有什么关系?

后面会单独讲:

复制代码
HttpRequestRetry

它首先要回答:

什么错误值得重试?

例如:

复制代码
Network
↓
某些情况下可以重试


Timeout
↓
某些情况下可以重试


503
↓
可能重试


401
↓
通常应该先走认证 / Refresh


404
↓
一般没有意义重试


Business Error
↓
通常也不应该自动重试

所以:

复制代码
Network
Timeout
HTTP
Business

必须先分清楚。

否则 Retry 很容易变成:

复制代码
失败了就再试几次

这是有问题的。


二十五、Network Pre-check 和 Retry 也有关系

假设:

复制代码
第一次请求
↓
网络失败
↓
准备 Retry

如果此时设备已经明确断网:

复制代码
NetworkConnectivityProvider
↓
No Network

那么再连续:

复制代码
Retry 1
Retry 2
Retry 3

其实都没有意义。

所以后面设计 Retry 时,也可以考虑:

复制代码
准备 Retry
↓
检查当前网络条件
↓
明确断网
↓
不要继续无意义 Retry

这也是请求前 Connectivity 状态检查具有价值的地方之一。

不过:

复制代码
Retry

属于后面的 Plugin 专题,这里只建立概念。


二十六、网络错误体系不要过度细分

底层可能存在:

复制代码
DNS Failure

Connection Refused

Socket Closed

SSL Handshake

Certificate Error

Route Failure

Proxy Failure

这些异常确实不同。

但是业务层是否需要:

复制代码
7 种不同 UI

通常不需要。

因此 AppError 的设计原则应该是:

按照业务真正需要处理的粒度抽象,而不是按照底层异常类一比一复制。

例如:

复制代码
很多连接类异常
↓
AppError.Network

就已经足够。

只有业务真的需要时,再增加:

复制代码
SSL
Certificate
Security

等分类。


二十七、AppError 的目的就是稳定上层

假设当前 Android 使用:

复制代码
Ktor + OkHttp Engine

以后可能换:

复制代码
Ktor CIO

iOS:

复制代码
Darwin Engine

Web:

复制代码
JS / Wasm Engine

不同平台底层抛出的具体异常细节可能并不完全一样。

但是 commonMain 上层最好一直面对:

复制代码
AppError.Network

AppError.Timeout

AppError.Unauthorized

AppError.Parse

所以:

复制代码
Engine / Platform Exception
↓
ExceptionMapper
↓
AppError

是 KMP 中特别有价值的一层抽象。


二十八、一个 ExceptionMapper 可以这样理解

先不纠结平台所有具体异常类,概念上:

Kotlin 复制代码
class ExceptionMapper {

    fun map(
        throwable: Throwable,
    ): AppError {

        return when {

            isTimeout(throwable) -> {
                AppError.Timeout
            }

            isNetworkError(throwable) -> {
                AppError.Network
            }

            isUnauthorized(throwable) -> {
                AppError.Unauthorized
            }

            isNotFound(throwable) -> {
                AppError.NotFound
            }

            isServerError(throwable) -> {
                AppError.Server(
                    statusCode =
                        getStatusCode(
                            throwable,
                        ),
                )
            }

            isSerializationError(
                throwable
            ) -> {
                AppError.Parse
            }

            throwable is ApiException -> {
                AppError.Business(
                    code = throwable.code,
                    message = throwable.message,
                )
            }

            else -> {
                AppError.Unknown(
                    cause = throwable,
                )
            }
        }
    }
}

真正项目中再根据:

复制代码
Ktor Version
Engine
Android / iOS / Web

处理具体异常类型。


二十九、NetworkClient 怎么接入网络检查和 ExceptionMapper?

现在完整一点:

Kotlin 复制代码
class NetworkClient(
    private val client: HttpClient,
    private val connectivityProvider:
        NetworkConnectivityProvider,
    private val exceptionMapper:
        ExceptionMapper,
)

执行逻辑可以理解成:

复制代码
NetworkClient
↓
先检查 Connectivity
↓
明确断网?
   ↓
   是
   ↓
AppError.Network

否则
↓
HttpClient
↓
Throwable
↓
ExceptionMapper
↓
AppError

也就是说:

复制代码
NetworkConnectivityProvider

负责的是:

复制代码
请求前状态判断

而:

复制代码
ExceptionMapper

负责的是:

复制代码
真正执行失败后的 Throwable 映射

两个职责不要混在一起。


三十、不要在 get/post 每个函数里重复 try/catch

如果:

复制代码
get()
post()
put()
delete()

全部写一遍:

复制代码
try {
    ...
} catch {
    ...
}

很快又重复。

所以可以抽:

复制代码
executeRequest()

例如概念上:

Kotlin 复制代码
suspend inline fun <reified T>
    executeRequest(
        crossinline request:
            suspend () -> HttpResponse,
    ): T {

    ensureNetworkAvailable()

    try {

        val response =
            request()
                .body<ApiResponse<T>>()

        if (
            response.code
            != ApiCode.SUCCESS
        ) {

            throw ApiException(
                code = response.code,
                message = response.msg,
            )
        }

        return response.data

    } catch (
        throwable: Throwable
    ) {

        if (
            throwable
            is CancellationException
        ) {
            throw throwable
        }

        throw mapException(
            throwable
        )
    }
}

于是:

复制代码
GET
POST
PUT
DELETE
↓
executeRequest
↓
Pre-check
↓
真正请求
↓
Response 解包
↓
异常统一

结构就非常清楚。


三十一、CancellationException 必须特别注意

协程项目中:

复制代码
CancellationException

通常不是:

复制代码
网络失败

而是:

复制代码
当前协程被取消

例如:

复制代码
页面退出

ViewModel Scope 取消

业务主动 cancel

所以如果:

复制代码
catch (Throwable)

一定要注意:

复制代码
if (
    throwable
    is CancellationException
) {
    throw throwable
}

不要把它:

复制代码
CancellationException
↓
AppError.Unknown

否则正常的协程取消行为会被错误地当成:

复制代码
请求失败

甚至 UI 可能出现错误提示。


三十二、为什么 CancellationException 要原样传播?

假设:

复制代码
页面关闭
↓
CoroutineScope cancel
↓
网络协程取消

这是:

复制代码
正常生命周期行为

不是:

复制代码
服务器异常
网络异常
业务异常

所以:

复制代码
Cancellation
≠
AppError

在大多数普通网络请求场景下,应该继续向上传播协程取消信号。


三十三、那 NetworkClient 应该 throw 还是返回 AppResult?

这里会出现两种设计。

第一种:异常模式

复制代码
suspend fun getUser(): User

成功:

复制代码
return User

失败:

复制代码
throw AppException

第二种:结果模式

例如:

Kotlin 复制代码
sealed interface AppResult<out T> {

    data class Success<T>(
        val data: T,
    ) : AppResult<T>

    data class Failure(
        val error: AppError,
    ) : AppResult<Nothing>
}

然后:

复制代码
suspend fun getUser():
    AppResult<User>

流程:

复制代码
成功
↓
Success<User>


失败
↓
Failure(AppError)

两种方式都有合理使用场景。

这一篇主要先把:

复制代码
错误从哪里产生
↓
怎么分类

讲清楚。

AppResult<T> 可以后面继续展开。


三十四、错误提示不要直接塞进 NetworkClient

例如:

复制代码
AppError.Network

NetworkClient 不应该直接:

复制代码
"网络不可用,请检查网络连接"

因为:

复制代码
AppError

负责的是:

复制代码
错误语义

而 UI:

复制代码
中文提示
英文提示
Toast
Dialog
页面状态

属于:

复制代码
UI / Presentation

可以进一步:

复制代码
AppError
↓
ErrorMessageMapper
↓
用户文案

尤其 KMP 项目需要考虑多语言和多平台,这样职责更清晰。


三十五、日志和用户提示也不能混

比如底层解析异常:

复制代码
Expected Int but found STRING
at $.data.id

这是:

复制代码
开发日志

用户不需要看到这些。

所以可以分成:

复制代码
Throwable
↓
Logging
↓
保留开发诊断信息


Throwable
↓
ExceptionMapper
↓
AppError.Parse


AppError.Parse
↓
UI
↓
"数据异常,请稍后重试"

日志、错误语义和用户提示是三层。


三十六、完整异常链路可以这样画

复制代码
                         Request
                            ↓
                NetworkConnectivityProvider
                            ↓
                    请求前网络检查
                     /             \
                  没网             有网
                   ↓                ↓
           AppError.Network      HttpClient
                                    ↓
                                  Engine
                                    ↓
                 ┌──────────────────┼─────────────────┐
                 ↓                  ↓                 ↓
              网络失败           Timeout          HTTP Response
                 ↓                  ↓                 ↓
         AppError.Network    AppError.Timeout    HTTP Status
                                                   ↓
                                      ┌────────────┼───────────┐
                                      ↓            ↓           ↓
                                     401          404         5xx
                                      ↓            ↓           ↓
                               Unauthorized    NotFound      Server
                                                   ↓
                                                  Body
                                                   ↓
                                             JSON Parse
                                                   ↓
                                            Parse Error
                                                   ↓
                                         ApiResponse<T>
                                                   ↓
                                              code != 0
                                                   ↓
                                            Business Error
                                                   ↓
                                                  data
                                                   ↓
                                                   T

这张图就是这篇真正需要建立的整体认知。


三十七、不要把不同错误层级混在一起

重点记住:

复制代码
请求前断网
≠
Timeout

运行中网络失败
≠
HTTP 500

HTTP 500
≠
业务 code != 0

业务 code != 0
≠
JSON 解析失败

JSON 解析失败
≠
Unknown

错误分类不是为了"分类好看"。

而是因为后续:

复制代码
Retry
Auth
Logging
UI
监控
业务处理

都会依赖这套错误模型。


三十八、KMP 中 ConnectivityProvider 还有一个额外价值

因为各个平台判断网络状态的方法不同。

概念上:

复制代码
commonMain

NetworkConnectivityProvider
          ↑
          │
   ┌──────┼──────┐
   ↓      ↓      ↓
Android  iOS    Web

例如 Android:

复制代码
ConnectivityManager
NetworkCapabilities

iOS、Web 则有自己的平台能力。

于是:

复制代码
NetworkClient

只知道:

复制代码
connectivityProvider.isConnected()

而不需要直接依赖:

复制代码
Android ConnectivityManager

这就是 KMP 中的平台能力抽象。

这一部分涉及:

复制代码
Android INTERNET / VALIDATED
iOS NWPathMonitor
Web navigator.onLine
状态监听
Pre-check

内容比较多,不在本篇展开。

后面单独作为:

补充篇 8.1:《Ktor/KMP 断网处理:为什么请求前要先判断网络状态?》

来详细讲。


三十九、最终推荐的基础 AppError

现阶段可以先从:

Kotlin 复制代码
sealed interface AppError {

    data object Network : AppError

    data object Timeout : AppError

    data object Unauthorized : AppError

    data object Forbidden : AppError

    data object NotFound : AppError

    data class Server(
        val statusCode: Int,
        val message: String? = null,
    ) : AppError

    data object Parse : AppError

    data class Business(
        val code: Int,
        val message: String,
    ) : AppError

    data class Unknown(
        val cause: Throwable? = null,
    ) : AppError
}

开始。

以后真实项目出现:

复制代码
SSL
Certificate
RateLimit
Maintenance

再根据业务需求增加。

不要一开始设计几十种错误。


四十、本篇总结

一次网络请求失败,并不只是一个:

复制代码
Exception

而是可能发生在不同阶段。


第一道:请求前快速失败

复制代码
NetworkConnectivityProvider
↓
明确没有互联网
↓
AppError.Network
↓
不进入 HttpClient / Engine

它的意义是:

已经知道请求不可能成功,就不要继续做无意义的网络工作。


第二道:真实网络请求兜底

即使请求前判断有网:

复制代码
网络也可能随后变化

所以:

复制代码
HttpClient
↓
Engine
↓
连接 / DNS / Socket 异常
↓
ExceptionMapper
↓
AppError.Network

仍然必须存在。


Timeout

复制代码
Connect Timeout
Request Timeout
Socket Timeout
↓
AppError.Timeout

它和:

复制代码
明确断网

不是同一回事。


HTTP

复制代码
401
403
404
5xx
↓
HTTP Error

JSON

复制代码
Serialization Error
↓
AppError.Parse

Business

复制代码
HTTP 200
↓
ApiResponse.code != 0
↓
AppError.Business

最终形成:

复制代码
请求前状态
      ↓
NetworkConnectivityProvider
      ↓
明确断网 → AppError.Network


真正请求
      ↓
Throwable
      ↓
ExceptionMapper
      ↓
AppError

业务层看到的是:

复制代码
Network
Timeout
Unauthorized
Forbidden
NotFound
Server
Parse
Business
Unknown

而不是:

复制代码
Ktor Exception
Engine Exception
Socket Exception
Serialization Exception

这一篇最重要的一句话可以更新成:

异常体系不是等请求失败以后才开始工作。

对于已经明确的断网,可以在请求真正进入 HttpClient/Engine 前快速失败;

而对于请求过程中发生的网络变化、Timeout、HTTP、解析和业务异常,再通过 ExceptionMapper 统一转换成 AppError。

两层共同组成完整的网络错误防线。


补充篇 8.1

《Ktor/KMP 断网处理:为什么请求前要先判断网络状态?》

专门继续讲:

复制代码
为什么要 Pre-check?

为什么已经有 ExceptionMapper
还要提前判断断网?

Android
ConnectivityManager
NetworkCapabilities

INTERNET
和
VALIDATED
有什么区别?

iOS 怎么判断?

Web 怎么判断?

KMP 怎么统一成
NetworkConnectivityProvider?

放 NetworkClient
还是自定义 Client Plugin?

为什么 Pre-check
仍然不能保证请求一定成功?

网络恢复以后怎么办?

Retry 和网络状态怎么配合?

下一篇

《Ktor Plugin 实战:Logging、HttpTimeout 与 HttpRequestRetry》

继续解决:

复制代码
Logging
↓
怎么记录请求 / 响应?


HttpTimeout
↓
Request / Connect / Socket
到底分别控制什么?


HttpRequestRetry
↓
Network / Timeout / 5xx
哪些应该重试?


POST
↓
为什么不能随便 Retry?
↓
幂等性

并正式把这一篇建立的:

复制代码
Network
Timeout
HTTP

错误分类和:

复制代码
Retry 策略

连接起来。

相关推荐
消失的旧时光-194317 小时前
补充篇 8.1:Ktor/KMP 断网处理:为什么请求前要先判断网络状态?
ktor·kmp·networkclient
消失的旧时光-194321 小时前
第七篇:Ktor 统一响应模型:ApiResponse、业务 code 与 data 解包
ktor·kmp·net
消失的旧时光-19439 天前
第一篇:Ktor Client 到底是什么?从 Retrofit 迁移理解 Ktor 网络请求架构
网络·架构·retrofit·ktor·dsl
小书房15 天前
KMP跨平台之数据库
数据库·kmp
繁星蓝雨1 个月前
C++设计原理——异常处理
java·c++·异常处理·noexcept·throw·try catch
凤山老林1 个月前
SpringBoot实战:构建优雅的全局异常处理机制
java·springboot·异常处理
长谷深风1111 个月前
Java基础知识梳理(四):异常体系、finally与统一异常处理
springboot·异常处理·java基础·项目·统一异常处理·异常·finally机制
消失的旧时光-19431 个月前
KMP Web 开发实战(二):js、wasmJs、browser、executable 到底是什么意思?
开发语言·前端·javascript·跨平台·kmp
名字还没想好☜2 个月前
Spring Boot 全局异常处理:@ControllerAdvice 实战
java·spring boot·后端·spring·异常处理