前面几篇,我已经把 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 策略
连接起来。