Language:Chinese VersionEnglish Version

没人会看的错误信息

这是一个来自真实生产系统的错误信息:Error: operation failed (code: 4012)。我在一个支持工单中发现了这个,客户已经花了三天时间试图搞清楚出了什么问题。写下这条信息的工程师可能只花了五秒钟,而客户却花了72小时。将这个时间乘以所有遇到这个错误的用户,你开始明白为什么错误信息是后端工程师能够做出的最高效的改进之一。

糟糕的错误信息不仅仅是用户体验问题——它们是运营成本中心。每个晦涩的错误都会生成支持工单。每个模糊的异常都会在事故期间减慢调试速度。每个误导性的信息都会将工程师引向错误的诊断路径。编写好的错误信息不是在”真正的工程”完成后做的润色工作。这就是工程。

有效错误信息的结构

一个有效的错误信息回答三个问题:

  1. 发生了什么? 对失败的精确描述。
  2. 为什么会发生? 被违反的条件或约束。
  3. 用户能做什么? 具体的下一步行动,或者至少是帮助他们摆脱困境的信息。

大多数错误信息只回答了第一个问题,而且回答得很糟糕。让我们看看常见场景中的真实例子:

身份验证错误

# 糟糕
{"error": "unauthorized"}

# 更好
{"error": "authentication_failed", 
 "message": "提供的API密钥对此环境无效。"}

# 最佳
{"error": "authentication_failed",
 "message": "以'sk_test_...'开头的API密钥是测试密钥,但此请求被发送到了生产端点(api.example.com)。请使用您的生产密钥(以'sk_live_'开头)或将请求发送到api-test.example.com。",
 "docs": "https://docs.example.com/authentication#environments"}

注意这个渐进过程。第一个告诉你没有任何可操作的信息。第二个告诉你出了什么问题。第三个确切地告诉你做了什么,为什么失败了,以及该怎么做。它甚至识别了特定的密钥格式,使诊断变得即时。

验证错误

# 糟糕
{"error": "invalid request"}

# 更好  
{"error": "validation_error",
 "message": "'email'字段无效。"}

# 最佳
{"error": "validation_error",
 "field": "email",
 "message": "邮箱地址'user@.com'无效。邮箱地址必须包含至少一个点的域名(例如,user@example.com)。",
 "received": "user@.com"}

在错误信息中包含接收到的值是一个小细节,却能节省大量的调试时间。没有它,用户必须弄清楚他们实际发送了什么,这可能涉及挖掘日志或重放请求。

速率限制

# 不佳
{"error": "too many requests"}

# 更好
{"error": "rate_limited",
 "message": "Rate limit exceeded."}

# 最佳
{"error": "rate_limited",
 "message": "You have exceeded the rate limit of 100 requests per minute for this endpoint. Your current usage: 142 requests in the last 60 seconds.",
 "limit": 100,
 "window": "60s",
 "current": 142,
 "retry_after": 23,
 "headers": {
   "X-RateLimit-Limit": "100",
   "X-RateLimit-Remaining": "0",
   "X-RateLimit-Reset": "1711540823"
 },
 "docs": "https://docs.example.com/rate-limits"}

API 错误消息模式

在审查了数十个生产环境 API 的错误处理后,我确定了能够持续产生有用错误消息的模式。

模式 1:结构化错误响应

在整个 API 中采用一致的错误响应结构。以下是行之有效的格式:

# Python / FastAPI 示例
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import Optional, List
import uuid

class ErrorDetail(BaseModel):
    field: Optional[str] = None
    message: str
    code: str

class ErrorResponse(BaseModel):
    error: str
    message: str
    request_id: str
    details: Optional[List[ErrorDetail]] = None
    docs: Optional[str] = None

@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    return JSONResponse(
        status_code=exc.status_code,
        content=ErrorResponse(
            error=exc.detail.get("code", "unknown_error"),
            message=exc.detail.get("message", str(exc.detail)),
            request_id=request.state.request_id,
            details=exc.detail.get("details"),
            docs=exc.detail.get("docs"),
        ).model_dump(exclude_none=True),
    )

# 使用方式:
raise HTTPException(
    status_code=422,
    detail={
        "code": "invalid_date_range",
        "message": "The end_date must be after the start_date. You provided start_date=2026-04-01 and end_date=2026-03-15.",
        "details": [
            {"field": "end_date", "message": "Must be after start_date", "code": "date_order"},
        ],
        "docs": "https://docs.example.com/api/orders#date-filtering",
    },
)

模式 2:作为契约的错误代码

数字错误代码对人类来说没有意义。字符串错误代码具有自文档特性,可作为稳定的 API 合约:

# 不好的:需要查找表的数字代码
{"error_code": 4012}  # 这是什么意思?

# 好的:自解释的字符串代码
{"error": "payment_method_expired"}
{"error": "insufficient_permissions"}  
{"error": "resource_not_found"}
{"error": "concurrent_modification"}

# 这些代码成为你 API 合约的一部分。
# 客户端可以编程方式匹配它们:

match response.error:
    case "payment_method_expired":
        prompt_user_to_update_payment()
    case "insufficient_permissions":
        request_elevated_access()
    case "resource_not_found":
        handle_missing_resource()
    case _:
        show_generic_error(response.message)

模式 3:上下文丰富的内部错误

内部错误消息(日志、跟踪、异常消息)应包含比面向用户的错误消息更多的上下文。包含值班工程师诊断问题所需的所有信息,无需额外查询:

import structlog

logger = structlog.get_logger()

async def process_order(order_id: str, user_id: str):
    order = await db.get_order(order_id)
    if not order:
        logger.error(
            "order_not_found",
            order_id=order_id,
            user_id=user_id,
            action="process_order",
            hint="Check if order was created in a different region or if it was soft-deleted",
        )
        raise OrderNotFoundError(
            f"Order {order_id} not found for user {user_id}. "
            f"Checked primary database in us-east-1. "
            f"Order may exist in a different region or may have been deleted."
        )
    
    if order.status != "pending":
        logger.warning(
            "order_invalid_state_transition",
            order_id=order_id,
            current_status=order.status,
            requested_transition="pending -> processing",
            hint="This usually indicates a duplicate webhook or race condition",
        )
        raise InvalidStateError(
            f"Cannot process order {order_id}: current status is '{order.status}', "
            f"but expected 'pending'. This may indicate a duplicate request. "
            f"Last status change: {order.updated_at.isoformat()}"
        )

常见反模式

反模式 1: 吞咽异常

# 最糟糕的做法
try:
    result = await external_api.call(payload)
except Exception:
    return {"error": "出错了"}  # 永远不要这样做

# 应该做的正确做法
try:
    result = await external_api.call(payload)
except ConnectionError as e:
    logger.error("external_api_connection_failed", 
                 endpoint=external_api.url, error=str(e))
    raise HTTPException(
        status_code=502,
        detail={
            "code": "upstream_connection_failed",
            "message": "无法连接到支付处理器。这通常是暂时的。请在30秒后重试。",
            "retry_after": 30,
        },
    )
except TimeoutError as e:
    logger.error("external_api_timeout",
                 endpoint=external_api.url, timeout_ms=5000)
    raise HTTPException(
        status_code=504,
        detail={
            "code": "upstream_timeout",
            "message": "支付处理器在5秒内未响应。您的支付可能仍在处理中。重试前请检查支付状态。",
        },
    )

反模式 2: 泄露内部细节

# 危险:暴露数据库架构和查询
{"error": "ProgrammingError: 列 users.ssn 不存在。 
  查询: SELECT ssn, name FROM users WHERE id = 42"}

# 安全:有意义的信息且不泄露内部细节
{"error": "internal_error",
 "message": "检索用户资料时发生内部错误。我们的团队已收到通知。",
 "request_id": "req_abc123"}

这里的 request_id 很重要。它给用户提供了一个可以包含在支持工单中的标识,让您的团队能够在日志中找到详细的错误信息,而不会暴露敏感信息。

反模式 3: 布尔错误字段

# 无用
{"success": false}

# 同样无用
{"ok": false, "error": true}

# 这些没有告诉消费者任何关于出了什么问题或该做什么的信息

测试错误消息

错误消息应该像测试正常流程行为一样有计划地进行测试。在您的测试套件中添加对错误消息质量的断言:

def test_expired_api_key_returns_helpful_error():
    response = client.get(
        "/api/users",
        headers={"Authorization": "Bearer sk_test_expired_key_123"}
    )
    assert response.status_code == 401
    body = response.json()
    
    # 验证错误结构
    assert "error" in body
    assert "message" in body
    assert "request_id" in body
    
    # 验证消息确实有帮助
    assert "expired" in body["message"].lower()
    assert "sk_test_" in body["message"]  # 引用特定密钥
    assert "docs" in body or "renew" in body["message"].lower()  # 可执行的操作

错误消息样式指南

以下是我与每个团队一起执行的规定:

  1. 不要只说”无效”或”错误”。 要说明什么内容无效以及为什么无效。
  2. 包含错误值(当它不敏感时)。”期望收到整数但收到了’abc'”比”type error”好得多。
  3. 使用用户的语言,而不是你的技术术语。 “Webhook URL必须使用HTTPS”比”TLS validation failed on callback_url”更好。
  4. 提供解决方案。 如果你知道用户应该怎么做,就告诉他们。
  5. 在每个错误响应中包含请求ID。 这是用户体验与你的内部日志之间的桥梁。
  6. 为有复杂解决方案的错误链接到文档。
  7. 永远不要向最终用户暴露堆栈跟踪、查询或内部路径。
  8. 区分客户端错误和服务器错误。 用户需要知道是应该修复他们的请求还是只需重试。

编写有用的错误信息并不是什么光鲜的工作。没有人会因为一个精心设计的422响应而称赞你。但你的未来自己、你的值班队友和你的用户会在每次遇到错误并立即知道如何处理时,默默地感谢你。这就是那种能产生复合效应的工程。

By Michael Sun

Founder and Editor-in-Chief of NovVista. Software engineer with hands-on experience in cloud infrastructure, full-stack development, and DevOps. Writes about AI tools, developer workflows, server architecture, and the practical side of technology. Based in China.

Leave a Reply

Your email address will not be published. Required fields are marked *

You missed