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 Request2. 身份与权限错误
例如:
没有登录 Token失效 没有操作权限分别对应:
401 4033. 资源状态错误
例如:
订单不存在 订单已经取消 库存不足属于业务规则的一部分。
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。
