三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Codex重构异常处理为什么容易把错误“吃掉”?别让try/catch掩盖真正Bug

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。

← 返回列表