错误处理与异常:统一异常处理、错误码设计、友好错误提示、重试机制

说实话,后端开发里最让人头疼的,往往不是业务逻辑有多复杂,而是那些你预料不到的异常。我记得刚带团队那会儿,线上一个接口突然返回了500,前端直接弹了个「服务器开小差」——用户一脸懵,我们也一脸懵。从那以后,我就在想:能不能让错误变得「可预测」、「可解释」、「可恢复」?

这一章,我们就来聊聊Ktor里怎么把异常处理做成体系。说白了,就是三件事:统一收口友好提示自动重试

一、统一异常处理:别让异常「裸奔」

Ktor默认的异常处理是分散的。你可以在每个路由里写try-catch,但那样太累了。我个人习惯用StatusPages插件来做全局拦截。

install(StatusPages) {
    exception<Throwable> { cause ->
        call.respond(HttpStatusCode.InternalServerError, mapOf(
            "code" to 500,
            "message" to "服务器内部错误"
        ))
    }
}

嗯,这里要注意:exception<Throwable>会捕获所有异常。但如果你有自定义异常,最好单独处理,别一股脑全吞了。

核心原则:越具体的异常,优先级越高。Ktor会按注册顺序匹配,所以把通用异常放最后。

二、错误码设计:给异常一个「身份证」

我在项目中遇到过一个问题:前端拿到错误后,只能根据message做if-else判断。后来我改成用错误码,前端直接查表,清晰多了。

错误码设计,我建议分三段:

  • 模块码(2位):比如01表示用户模块,02表示订单模块
  • 错误类型码(2位):比如01表示参数错误,02表示权限不足
  • 具体错误码(2位):从01开始递增
错误码 含义 HTTP状态码
010101 用户模块-参数错误-用户名不能为空 400
010201 用户模块-权限不足-未登录 401
020101 订单模块-参数错误-订单号格式错误 400
sealed class ApiError(val code: String, val message: String) {
    class UserNameEmpty : ApiError("010101", "用户名不能为空")
    class NotLogin : ApiError("010201", "请先登录")
    class OrderIdInvalid : ApiError("020101", "订单号格式错误")
}

你想想看,前端拿到010101,直接就能定位到是哪个模块、什么类型、具体哪个字段出了问题。比看一段中文message靠谱多了。

三、友好错误提示:别让用户看「天书」

我曾经见过一个接口返回:NullPointerException at line 47。这玩意儿用户看了能干嘛?

友好提示,我总结了三层:

  • 用户层:简单、可理解的话,比如「网络开小差,请稍后重试」
  • 开发者层:详细的错误信息,比如错误码、堆栈、请求ID
  • 日志层:完整的异常堆栈,方便排查
exception<ApiError> { error ->
    call.respond(HttpStatusCode.BadRequest, mapOf(
        "code" to error.code,
        "message" to error.message,
        "detail" to "如需帮助,请联系客服并提供错误码"
    ))
}

小技巧:在响应头里加一个X-Request-Id,用户反馈问题时,你直接查日志就能定位到那次请求。

四、重试机制:给「临时故障」一次机会

有些异常是临时的,比如网络抖动、数据库连接池满。直接返回失败太可惜了。我建议用重试机制来兜底。

Ktor客户端自带重试支持,但服务端处理外部调用时,也需要自己实现重试逻辑。

suspend fun <T> retryWithBackoff(
    maxRetries: Int = 3,
    initialDelay: Long = 100L,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    repeat(maxRetries) { attempt ->
        try {
            return block()
        } catch (e: Exception) {
            if (attempt == maxRetries - 1) throw e
            delay(currentDelay)
            currentDelay *= 2 // 指数退避
        }
    }
    throw IllegalStateException("不可达")
}

为什么用指数退避?你想想看,如果服务端正在恢复,你疯狂重试只会加重负担。等一会儿再试,反而成功率更高。

注意:重试只适用于幂等操作。比如查询、删除可以重试,但创建订单、扣款这种操作,重试前一定要做幂等校验。

五、整体架构:一张图看懂

我把整个异常处理体系画成了流程图,方便你理解各个组件的关系。

Ktor 异常处理体系架构 HTTP 请求 StatusPages 全局拦截器 异常类型? ApiError 自定义异常 临时异常(网络/超时) Throwable 兜底 返回错误码 + 友好提示 重试机制(指数退避) 返回500 + 日志记录 统一响应格式

从图上你能看到:请求进来后,先经过StatusPages拦截器。根据异常类型走不同分支——自定义异常返回错误码,临时异常触发重试,其他异常兜底返回500。最终所有响应都统一格式输出。

六、实战建议

最后,我分享几个实战中踩过的坑:

  • 不要吞异常:我曾经为了省事,在catch块里直接返回null。结果排查问题时完全不知道发生了什么。一定要记录日志。
  • 重试要有上限:无限重试等于拒绝服务。我一般设3次,每次间隔翻倍。
  • 错误码要文档化:把错误码和对应的含义整理成文档,前后端各存一份。沟通成本能降一半。

我的习惯:在开发环境,异常信息返回完整堆栈;生产环境只返回错误码和友好提示。用environment判断环境,一行代码搞定。

异常处理这件事,说白了就是「防患于未然」。你提前想好了怎么收口、怎么提示、怎么恢复,线上出问题时就不会手忙脚乱。嗯,这一章的内容就到这里,希望对你有所帮助。


公众号:蓝海资料掘金营,微信deep3321