使用 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。

更多推荐