大多数错误处理是为已经了解系统的工程师编写的。
这完全是本末倒置。
用户并不关心 Stripe 的 webhook 超时、Supabase 策略拒绝了某行数据,或者模型提供商返回了 429 状态码。他们只关心三件事:
- 发生了什么
- 他们的工作是否安全
- 接下来能做什么
如果界面无法回答这些问题,那么错误信息就没有起到帮助作用,只是在泄露实现细节。
从用户的任务出发,而非异常
错误信息的第一版草稿通常听起来像代码路径:
创建结账会话失败。
这可能是事实,但毫无用处。更好的版本应从用户意图出发:
我们无法打开结账页面。您的项目详情已保存。请重试,或预约电话,我们将手动为您完成。
这条信息完成了四项工作:
- 说明了失败的操作
- 确认了数据是否已保存
- 提供了下一步操作
- 避免指责用户
内部错误仍然可以记录提供商、状态码、请求 ID 和堆栈跟踪。用户不需要所有这些信息。
将用户文案与工程遥测分离
产品界面和可观测性界面不应承载相同的内容。
用户看到清晰的恢复路径。系统保留堆栈跟踪、请求 ID、提供商响应和告警路由供操作员使用。
在生产环境中,我希望同一个故障产生两个输出:
- 页面上人类可读的消息
- 日志、分析和告警中机器可读的事件
用户文案应冷静且具体。遥测数据如果需要可以密集且难看。将这两者混在一起,要么产生无用的日志,要么产生不友好的界面。
良好的错误状态应回答五个问题
当我审查错误状态时,我会用这个清单逐一检查。
如果答案是否定的,那么这个状态还没有完成。
例如,潜在客户表单失败不应显示 500 Internal Server Error。它应该更接近这样的表述:
我们无法发送消息。您的浏览器仍停留在此页面,因此没有丢失任何内容。请重试,或直接通过电子邮件发送项目详情。
然后服务器日志应承载实际原因:验证失败、Resend 超时、Supabase 插入失败或 webhook 拒绝。
在系统故障之前设计降级方案
团队通常是在第一次生产事故后才添加降级状态。这样做代价高昂,因为故障已经公开了。
对于重要的流程,我喜欢在构建功能时就定义降级方案:
| 流程 | 用户降级方案 | 操作员信号 |
|---|---|---|
| 结账 | 保存路由,提供预约链接 | 支付提供商错误,附带会话元数据 |
| 联系表单 | 在屏幕上保留消息,显示直接邮箱 | 潜在客户捕获错误,附带来源和载荷结构 |
| AI 生成 | 保留提示词,提供重试 | 提供商、模型、延迟和令牌元数据 |
| 文件上传 | 显示文件限制和重试路径 | 存储错误、大小、MIME 类型、组织 ID |
降级方案不需要花哨。它需要保持用户的进度。
不要让每个错误听起来都一样
通用的消息会让产品显得粗心大意:
- 出了点问题。
- 请稍后重试。
- 发生了意外错误。
有时这些作为最后的兜底信息是可以接受的,但它们不应成为产品中唯一的错误语言。
不同的故障需要不同的恢复路径:
- 验证错误:显示具体字段和预期格式
- 权限错误:说明需要什么角色或账户
- 速率限制:说明何时重试或提供更轻量的操作
- 依赖故障:保留用户的工作并显示替代路径
- 破坏性操作失败:明确说明哪些内容没有改变
目标不是让系统看起来完美无缺。目标是在系统不完美时让用户感到有方向感。
操作员需要不同的界面
尊重用户的文案只有在操作员仍然能获得真实证据时才能发挥作用。
这意味着要记录:
- 路由和操作
- 请求 ID 或跟踪 ID
- 用户/组织 ID(如可用)
- 提供商和状态码
- 安全的载荷结构
- 时间信息
- 重试次数
同时意味着不记录密钥、原始令牌、支付卡详情、私人文档,或可能包含客户数据的完整提示词。
良好的错误处理不是更温和的日志记录,而是更清晰的分离。
我努力交付的模式
对于每个重要操作,我希望实现这样的结构:
- 尽早验证并显示字段级别的指导。
- 将服务器操作/API 路由包裹在结构化的错误处理中。
- 返回稳定的用户消息和稳定的机器代码。
- 记录完整的操作员安全上下文。
- 如果影响转化率,将故障作为产品事件进行跟踪。
- 尽可能保留用户输入。
这不是什么光鲜的工作,但它是高级体验的一部分。能保存你的工作并告诉你下一步该做什么的网站,比那些弹出一个红色框让你重新开始的网站更值得信赖。
