# Research: Fiber 错误处理集成 **Feature**: 003-error-handling **Date**: 2025-11-14 **Status**: Complete ## 研究目标 解决实施 Fiber 错误处理集成时的技术不确定性和最佳实践。 ## 研究任务 ### 1. Fiber 框架错误处理机制 **研究问题**: Fiber 如何实现全局错误处理?如何与中间件链配合? **决策**: 使用 Fiber 的 `ErrorHandler` 配置项实现全局错误处理 **技术方案**: ```go 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)的错误 **参考资料**: - [Fiber Error Handling](https://docs.gofiber.io/guide/error-handling) - 现有代码: `cmd/api/main.go` 中的 Fiber 应用配置 --- ### 2. ErrorHandler 自身保护机制 **研究问题**: 如何防止 ErrorHandler 本身发生错误或 panic 导致无限循环或服务崩溃? **决策**: 使用 defer + recover 保护 ErrorHandler,失败时返回最简响应 **技术方案**: ```go 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. 敏感信息识别和隐藏 **研究问题**: 如何自动识别并隐藏错误消息中的敏感信息(数据库错误、文件路径、密钥等)? **决策**: 为所有内部错误返回通用错误消息,原始错误仅记录到日志 **技术方案**: ```go 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. 响应已发送后的错误处理 **研究问题**: 当响应已经部分发送给客户端后发生错误,如何处理? **决策**: 检测响应状态,已发送则仅记录日志不修改响应 **技术方案**: ```go 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. 日志系统集成 **研究问题**: 如何确保错误处理不因日志系统失败而阻塞请求? **决策**: 日志记录采用静默失败策略,日志失败不影响响应 **技术方案**: ```go 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 状态码和日志级别? **决策**: 基于错误码范围分类,统一映射规则 **技术方案**: ```go // 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),不在响应体中 **技术方案**: ```go 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 错误返回通用消息 - 日志访问受限(仅运维团队) - 堆栈跟踪仅记录到日志,不返回给客户端 - 敏感字段(密码、密钥)不记录到日志 --- **研究完成**: ✅ 所有技术不确定性已解决,可以进入设计阶段