Files
junhong_cmp_fiber/specs/003-error-handling/research.md
huang fb83c9a706 feat: 实现统一错误处理系统 (003-error-handling)
- 新增统一错误码定义和管理 (pkg/errors/codes.go)
- 新增全局错误处理器和中间件 (pkg/errors/handler.go, internal/middleware/error_handler.go)
- 新增错误上下文管理 (pkg/errors/context.go)
- 增强 Panic 恢复中间件 (internal/middleware/recover.go)
- 新增完整的单元测试和集成测试
- 新增功能文档 (docs/003-error-handling/)
- 新增功能规范 (specs/003-error-handling/)
- 更新 CLAUDE.md 和 README.md
2025-11-15 12:17:44 +08:00

12 KiB
Raw Blame History

Research: Fiber 错误处理集成

Feature: 003-error-handling
Date: 2025-11-14
Status: Complete

研究目标

解决实施 Fiber 错误处理集成时的技术不确定性和最佳实践。

研究任务

1. Fiber 框架错误处理机制

研究问题: Fiber 如何实现全局错误处理?如何与中间件链配合?

决策: 使用 Fiber 的 ErrorHandler 配置项实现全局错误处理

技术方案:

app := fiber.New(fiber.Config{
    ErrorHandler: customErrorHandler,
    // ... 其他配置
})

func customErrorHandler(c *fiber.Ctx, err error) error {
    // 1. 检查是否已发送响应
    if c.Response().StatusCode() != fiber.StatusOK {
        // 已发送响应,仅记录日志
        logger.Error("响应已发送后发生错误", zap.Error(err))
        return nil
    }
    
    // 2. 处理不同类型的错误
    // 3. 返回统一格式的错误响应
}

理由:

  • Fiber 的 ErrorHandler 是捕获所有返回错误的最后一道防线
  • 与中间件链自然集成,所有 c.Next() 返回的错误都会被捕获
  • 可以统一处理来自不同层Handler、Middleware的错误

参考资料:


2. ErrorHandler 自身保护机制

研究问题: 如何防止 ErrorHandler 本身发生错误或 panic 导致无限循环或服务崩溃?

决策: 使用 defer + recover 保护 ErrorHandler失败时返回最简响应

技术方案:

func SafeErrorHandler(logger *zap.Logger) fiber.ErrorHandler {
    return func(c *fiber.Ctx, err error) error {
        defer func() {
            if r := recover(); r != nil {
                // ErrorHandler 自身 panic返回空响应避免崩溃
                logger.Error("ErrorHandler panic",
                    zap.Any("panic", r),
                    zap.String("stack", string(debug.Stack())),
                )
                _ = c.Status(500).SendString("") // 空响应体
            }
        }()
        
        // 正常的错误处理逻辑
        return handleError(c, err, logger)
    }
}

理由:

  • 符合 spec.md FR-009 要求ErrorHandler 必须使用 defer + recover 保护
  • 当 ErrorHandler 失败时返回 HTTP 500 空响应体,避免泄露错误信息
  • 确保即使 ErrorHandler 崩溃也不会影响服务可用性
  • 将 panic 详情记录到日志以供排查

边界情况:

  • 日志系统不可用:静默失败,丢弃日志(符合 spec.md clarification
  • JSON 序列化失败:已被 defer/recover 捕获,返回空响应

3. 敏感信息识别和隐藏

研究问题: 如何自动识别并隐藏错误消息中的敏感信息(数据库错误、文件路径、密钥等)?

决策: 为所有内部错误返回通用错误消息,原始错误仅记录到日志

技术方案:

func sanitizeErrorMessage(err error, code int) string {
    // 所有 5xx 错误返回通用消息
    if code >= 500 {
        return "内部服务器错误"
    }
    
    // 4xx 错误可以返回具体的业务错误消息
    // 但必须使用预定义的错误码和消息,不直接暴露原始错误
    if appErr, ok := err.(*errors.AppError); ok {
        return appErr.Message
    }
    
    // 其他错误返回通用消息
    return "请求处理失败"
}

// 错误处理流程
func handleError(c *fiber.Ctx, err error, logger *zap.Logger) error {
    // 1. 完整错误记录到日志(包含敏感信息)
    logger.Error("请求处理错误",
        zap.Error(err),
        zap.String("path", c.Path()),
        // ... 更多上下文
    )
    
    // 2. 返回脱敏的错误消息给客户端
    sanitized := sanitizeErrorMessage(err, code)
    return c.Status(httpStatus).JSON(Response{
        Code:    code,
        Message: sanitized, // 不包含原始错误详情
        // ...
    })
}

理由:

  • 符合 spec.md FR-007: 隐藏内部实现细节和敏感信息
  • 符合 clarification: 为所有错误返回通用消息,原始详情仅记录到日志
  • 避免泄露数据库结构、文件路径、堆栈跟踪等敏感信息
  • 日志系统已配置访问控制(运维团队可访问),符合安全要求

不采用的方案:

  • 正则表达式过滤敏感信息:复杂、易遗漏、性能开销
  • 白名单机制:维护成本高,容易过时
  • 统一返回通用消息:简单、安全、可靠

4. 响应已发送后的错误处理

研究问题: 当响应已经部分发送给客户端后发生错误,如何处理?

决策: 检测响应状态,已发送则仅记录日志不修改响应

技术方案:

func handleError(c *fiber.Ctx, err error, logger *zap.Logger) error {
    // 检查响应是否已发送
    if c.Response().StatusCode() != fiber.StatusOK || 
       len(c.Response().Body()) > 0 {
        // 响应已发送,仅记录日志
        logger.Error("响应已发送后发生错误",
            zap.Error(err),
            zap.Int("status", c.Response().StatusCode()),
            zap.Int("body_size", len(c.Response().Body())),
        )
        return nil // 不再修改响应
    }
    
    // 响应未发送,正常处理错误
    return buildErrorResponse(c, err)
}

理由:

  • 符合 spec.md FR-001 和 edge case: 响应已部分发送时仅记录日志
  • 修改已发送的响应会导致响应格式损坏(如 JSON 不完整)
  • Fiber 的 c.Response().StatusCode()c.Response().Body() 可以检测发送状态
  • 静默失败策略确保不会因错误处理导致更严重的问题

替代方案(不采用):

  • 尝试清空响应重新发送Fiber 不支持,会导致客户端接收损坏数据
  • 抛出 panic违反设计原则应该优雅降级

5. 日志系统集成

研究问题: 如何确保错误处理不因日志系统失败而阻塞请求?

决策: 日志记录采用静默失败策略,日志失败不影响响应

技术方案:

func logError(logger *zap.Logger, fields ...zap.Field) {
    defer func() {
        if r := recover(); r != nil {
            // 日志系统 panic静默丢弃
            // 不记录到任何地方,避免无限循环
        }
    }()
    
    // 尝试记录日志
    logger.Error("错误", fields...)
}

func handleError(c *fiber.Ctx, err error, logger *zap.Logger) error {
    // 1. 尝试记录日志(可能失败)
    logError(logger, 
        zap.Error(err),
        zap.String("path", c.Path()),
    )
    
    // 2. 无论日志是否成功,都继续返回响应
    return buildErrorResponse(c, err)
}

理由:

  • 符合 spec.md FR-005 clarification: 日志失败时静默处理
  • 符合 edge case: 日志系统不可用时丢弃日志,确保请求不受影响
  • Zap logger 本身已经有 panic 保护,但显式的 defer/recover 提供额外保障
  • 请求处理优先级高于日志记录

现有代码分析:

  • pkg/logger/logger.go 已使用 Zap支持异步日志
  • internal/middleware/recover.go 已正确处理日志记录
  • 需要确保 ErrorHandler 中的日志调用也采用相同策略

6. 错误分类和 HTTP 状态码映射

研究问题: 如何将不同类型的错误映射到合适的 HTTP 状态码和日志级别?

决策: 基于错误码范围分类,统一映射规则

技术方案:

// pkg/errors/codes.go
const (
    // 成功
    CodeSuccess = 0
    
    // 客户端错误 (1000-1999) -> 4xx
    CodeInvalidParam    = 1001 // 400
    CodeUnauthorized    = 1002 // 401
    CodeForbidden       = 1003 // 403
    CodeNotFound        = 1004 // 404
    
    // 服务端错误 (2000-2999) -> 5xx
    CodeInternalError   = 2001 // 500
    CodeDatabaseError   = 2002 // 500
    CodeServiceUnavailable = 2003 // 503
)

func GetHTTPStatus(code int) int {
    switch {
    case code >= 1000 && code < 2000:
        return mapClientError(code)
    case code >= 2000 && code < 3000:
        return mapServerError(code)
    default:
        return 500
    }
}

func GetLogLevel(code int) string {
    if code >= 2000 {
        return "error" // 服务端错误
    } else if code >= 1000 {
        return "warn"  // 客户端错误
    }
    return "info"
}

理由:

  • 符合 spec.md FR-006: 区分客户端和服务端错误
  • 错误码范围映射清晰,易于扩展
  • 日志级别与错误严重性匹配(客户端错误 = Warn服务端错误 = Error
  • 便于监控和告警(可以基于错误码范围设置不同的告警策略)

现有代码扩展:

  • pkg/errors/codes.go 需要定义完整的错误码枚举
  • pkg/errors/errors.go 已有 AppError 类型,无需修改

7. Request ID 传递

研究问题: 如何在错误响应中关联 Request ID

决策: Request ID 仅在响应 Header 中传递X-Request-ID不在响应体中

技术方案:

func handleError(c *fiber.Ctx, err error, logger *zap.Logger) error {
    // 1. 获取 Request ID
    requestID := c.Get("X-Request-ID", "")
    if requestID == "" {
        if rid := c.Locals(constants.ContextKeyRequestID); rid != nil {
            requestID = rid.(string)
        }
    }
    
    // 2. 设置响应 Header
    c.Set("X-Request-ID", requestID)
    
    // 3. 日志中包含 Request ID
    logger.Error("请求处理错误",
        zap.String("request_id", requestID),
        zap.Error(err),
    )
    
    // 4. 响应体不包含 request_id 字段
    return c.Status(httpStatus).JSON(Response{
        Code:    code,
        Message: message,
        // 不包含 request_id
    })
}

理由:

  • 符合 spec.md FR-008 clarification: 不在响应体中包含 request_id
  • 通过响应 Header X-Request-ID 传递,客户端可以获取用于追踪
  • 日志中包含 request_id可以关联同一请求的所有日志条目
  • 符合 HTTP 标准实践Request ID 通常在 Header 中)

现有代码集成:

  • cmd/api/main.go 已使用 requestid.New() 中间件生成 Request ID
  • internal/middleware/recover.go 已从 c.Locals() 获取 Request ID
  • ErrorHandler 需要采用相同的获取方式

研究总结

技术栈确认

  • 框架: Fiber v2 (已使用)
  • 日志: Zap (已使用)
  • 错误包: 标准库 errors + 自定义 pkg/errors (已有基础)
  • JSON: sonic (已配置)

核心设计决策

  1. 全局错误处理: 使用 Fiber ErrorHandler + defer/recover 双层保护
  2. 敏感信息隐藏: 统一返回通用错误消息,原始错误仅记录日志
  3. 日志策略: 异步日志 + 静默失败,不阻塞请求
  4. 错误分类: 基于错误码范围映射 HTTP 状态码和日志级别
  5. Request ID: 通过 Header 传递,不在响应体中

需要实现的组件

组件 路径 描述
错误码定义 pkg/errors/codes.go 完整的错误码枚举和消息映射
全局 ErrorHandler pkg/errors/handler.go Fiber ErrorHandler 实现
Recover 中间件增强 internal/middleware/recover.go 已有,可能需要小幅调整
错误辅助函数 pkg/errors/helpers.go HTTP 状态码映射、日志级别映射

性能考虑

  • 错误处理延迟目标: < 1ms
  • 使用预分配的错误对象避免频繁内存分配
  • 日志记录异步执行Zap 已支持)
  • 避免复杂的字符串处理(如正则匹配)

安全考虑

  • 所有 5xx 错误返回通用消息
  • 日志访问受限(仅运维团队)
  • 堆栈跟踪仅记录到日志,不返回给客户端
  • 敏感字段(密码、密钥)不记录到日志

研究完成: 所有技术不确定性已解决,可以进入设计阶段