Codex重构异常处理为什么容易把错误“吃掉”?别让try/catch掩盖真正Bug
使用 Codex 修复项目报错时,经常会看到一种很“有效”的修改:
原来程序会直接抛出异常,修改以后页面不报错了、接口也不再返回 500,测试甚至可能重新变绿。
但继续使用一段时间后,却会发现新的问题:
-
接口明明失败了,前端却显示“暂无数据”;
-
数据库写入失败,业务仍然返回成功;
-
日志里只剩一句
Something went wrong; -
外部接口超时后被自动忽略;
-
真实 Bug 没有解决,只是异常不再显示;
-
同一个错误被重复重试几十次;
-
线上数据出现异常,却找不到第一次失败的位置。
这种情况通常可以称为:错误被吞掉了。
Codex 并不是不会处理异常,而是如果任务只要求“不要再报错”,最简单的实现往往就是增加 try/catch。
但“程序不报错”和“问题被正确处理”,完全是两件事。
一、最危险的catch:什么都不做
例如:
try {
await saveOrder(order);
} catch (error) {
// ignore
}
从程序运行角度看,它确实不会继续抛异常。
但假设 saveOrder() 因数据库连接失败没有保存订单,后面的代码仍然继续执行:
await sendOrderCreatedNotification(order);
最终可能出现:
数据库:没有订单
用户:收到订单创建成功通知
系统状态已经不一致。
所以,一个异常是否应该捕获,首先要回答:
当前这一层真的知道应该怎么处理这个错误吗?
如果不知道,通常就不应该直接吞掉。
二、catch后return null也可能在隐藏问题
另一种常见写法:
async function getUser(id: string) {
try {
return await userRepository.findById(id);
} catch (error) {
return null;
}
}
调用方看到 null 后,会认为:
用户不存在
但真实原因可能是:
数据库连接失败
SQL执行异常
连接池耗尽
这样就把两类完全不同的状态混在一起了:
正常业务结果:用户不存在
系统错误:数据库查询失败
更合理的写法是明确区分:
async function getUser(id: string) {
try {
return await userRepository.findById(id);
} catch (error) {
throw new UserQueryError(
"Failed to query user",
{ cause: error }
);
}
}
业务层可以继续判断真正的“用户不存在”,而系统异常则进入统一错误处理。
三、不要把所有异常都转成同一个错误
Codex 为了统一接口返回,有时会生成:
try {
await service.run();
} catch {
throw new Error("Operation failed");
}
这样虽然看起来整洁,却丢失了最重要的信息。
原始错误可能分别是:
ValidationError
PermissionError
DatabaseTimeoutError
ExternalApiError
但最后全部变成:
Operation failed
线上排查时几乎无法判断到底发生了什么。
更合理的做法是保留错误类型和原始原因:
throw new OrderCreateError(
"Failed to create order",
{
cause: error,
orderId
}
);
这样既可以提供业务上下文,又不会丢失底层异常链。
四、建立明确的错误类型
对于中大型项目,可以将异常大致分成几类。
1. 参数错误
例如:
用户名为空
分页参数小于0
文件类型不支持
通常属于客户端可修正问题。
可以返回:
400 Bad Request
2. 身份与权限错误
例如:
没有登录
Token失效
没有操作权限
分别对应:
401
403
3. 资源状态错误
例如:
订单不存在
订单已经取消
库存不足
属于业务规则的一部分。
4. 基础设施错误
例如:
数据库超时
Redis不可用
第三方接口失败
这种错误通常不能简单伪装成“数据不存在”。
5. 未知错误
真正没有预料到的异常,需要进入统一日志和告警流程。
错误分类清晰以后,Codex 才不容易用一个 catch 处理所有问题。
五、只在“能真正处理”的层级捕获错误
一个很实用的原则是:
谁能做出有效决策,谁才捕获错误。
例如 Repository 层:
async function findUser(id: string) {
return db.user.findUnique({
where: { id }
});
}
如果数据库发生错误,Repository 不一定需要立刻:
catch {
return null;
}
因为它无法判断上层业务希望怎么处理。
Service 层可能更清楚:
const user = await userRepository.findUser(id);
if (!user) {
throw new UserNotFoundError(id);
}
而 API 层负责最终转换:
UserNotFoundError
→ 404
PermissionDeniedError
→ 403
ValidationError
→ 400
未知异常则统一返回:
500
这样错误处理链会更清晰。
六、不要在每一层都重复记录同一个错误
另一个常见问题是:
Repository:
logger.error(error);
throw error;
Service:
logger.error(error);
throw error;
Controller:
logger.error(error);
throw error;
最终一条异常可能生成三四条几乎相同的日志。
线上看到:
ERROR database timeout
ERROR database timeout
ERROR database timeout
反而无法判断哪个才是真正的错误入口。
更好的做法是:
-
底层增加必要上下文;
-
最终统一错误边界记录一次完整日志;
-
中间层只在真正增加业务信息时记录。
例如统一输出:
traceId
errorType
operation
userId
orderId
cause
duration
而不是每层都 console.error()。
七、重试不是所有错误的解决方案
Codex 遇到网络错误时,很容易建议自动重试。
但下面这些错误不应该重试:
400 参数错误
401 未认证
403 无权限
404 资源不存在
业务状态不允许
这些问题重试多少次结果都一样。
更适合重试的是临时性错误:
网络抖动
连接超时
429限流
部分5xx错误
临时数据库连接异常
可以明确写:
function isRetryable(error: unknown) {
return (
error instanceof NetworkTimeoutError ||
error instanceof RateLimitError
);
}
而不是:
catch {
retry();
}
八、禁止无限递归重试
这种代码风险很高:
async function request() {
try {
return await api.call();
} catch {
return request();
}
}
如果服务持续不可用,就会无限重试。
更合理的是设置:
最大次数
等待时间
错误类型
最终失败状态
例如:
for (let attempt = 1; attempt <= 3; attempt++) {
try {
return await api.call();
} catch (error) {
if (!isRetryable(error) || attempt === 3) {
throw error;
}
await sleep(attempt * 1000);
}
}
重试的目标是处理暂时故障,而不是让错误永远无法暴露。
九、finally也可能带来隐藏Bug
finally 无论成功还是失败都会执行。
例如:
try {
await saveData();
} finally {
setStatus("completed");
}
即使 saveData() 报错,状态仍然会变成:
completed
这显然不正确。
更合理的是:
try {
await saveData();
setStatus("completed");
} catch (error) {
setStatus("failed");
throw error;
} finally {
setLoading(false);
}
finally 更适合:
关闭连接
释放锁
停止Loading
清理临时资源
而不是写业务成功状态。
十、前端不要把所有异常显示成“网络错误”
接口可能返回:
400 参数错误
403 权限不足
409 状态冲突
429 请求过快
500 服务异常
如果前端全部显示:
网络异常,请稍后重试
用户和开发者都会失去重要信息。
可以建立错误映射:
switch (error.code) {
case "ORDER_ALREADY_CANCELLED":
return "订单已经取消";
case "PERMISSION_DENIED":
return "当前账号没有操作权限";
default:
return "系统暂时无法完成操作";
}
用户提示可以友好,但日志仍应保留真实错误信息。
十一、让Codex先画出错误传播链
处理异常问题时,可以先要求:
请先不要修改代码。
分析当前错误传播链:
1. 错误最初在哪里产生;
2. 经过哪些函数;
3. 哪一层第一次catch;
4. 是否修改了错误类型;
5. 是否存在return null / [] / false;
6. 是否发生重复日志;
7. 最终API返回什么。
很多“偶发Bug”其实一看传播链就能发现:
DatabaseError
↓
catch
↓
return null
↓
被当成用户不存在
↓
返回404
真实数据库故障就这样被隐藏了。
十二、测试必须验证错误路径
不要只测试:
数据库正常
→ 用户查询成功
还应该测试:
数据库超时
→ 不能返回404
权限不足
→ 必须返回403
参数错误
→ 不允许继续调用数据库
外部接口失败
→ 不能返回成功状态
重试达到上限
→ 必须暴露最终错误
例如:
it(
"does not convert database failure to user not found",
async () => {
repository.findUser.mockRejectedValue(
new DatabaseTimeoutError()
);
await expect(
service.getUser("1001")
).rejects.toThrow(DatabaseTimeoutError);
}
);
这种测试可以防止未来 Codex 再次为了“让接口稳定”而吞掉真实错误。
十三、把异常处理规则写进AGENTS.md
可以加入:
# 异常处理规则
- 禁止空catch
- 禁止无理由return null掩盖系统错误
- 保留原始error cause
- 业务错误与系统错误必须区分
- 只有可恢复错误允许自动重试
- 所有重试必须设置最大次数
- finally只用于清理资源,不表示业务成功
- 不允许为了消除500直接吞掉异常
- 修改异常流程后必须增加失败路径测试
这类规则对 Codex 很有帮助。
因为模型在修复问题时会更明确:
目标不是让错误消失,而是让错误被正确处理。
十四、一个推荐的错误处理结构
可以采用:
Repository
↓
产生底层错误
↓
Service
增加业务上下文
↓
Controller / Error Boundary
转换成统一响应
↓
Logger
记录一次完整异常
↓
用户
看到安全、可理解的提示
错误应该被转换,而不是被消失。
十五、Plus还是Pro?
如果主要是:
单接口异常处理
普通Bug排查
简单try/catch重构
少量失败测试
Plus 通常足够。
如果需要长期处理大型仓库、复杂调用链、微服务错误传播、大量日志和多轮测试,则可以根据实际开发强度评估 Pro。
不过更大的使用空间只能帮助连续分析。
错误是否被正确处理,最终还是取决于项目自己的异常边界设计。
总结
Codex 重构异常处理以后,程序“不再报错”并不一定是好事。
真正危险的是:
错误发生了
↓
被catch
↓
被转换成null
↓
业务继续运行
↓
系统表面正常
通过错误分类、明确传播边界、保留原始 cause、限制重试和覆盖失败路径测试,可以避免 try/catch 变成隐藏 Bug 的工具。
真正可靠的异常处理,不是让系统永远不出现错误,而是确保每个错误发生以后:
能够被识别、被记录、被正确响应,并且不会悄悄破坏后续业务状态。
CSDN文章描述
本文介绍 Codex 重构异常处理时常见的“吞错”问题,通过错误类型、传播边界、重试规则、统一日志和失败路径测试,避免 try/catch 掩盖真实 Bug。
更多推荐
所有评论(0)