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

377 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 错误返回通用消息
- 日志访问受限(仅运维团队)
- 堆栈跟踪仅记录到日志,不返回给客户端
- 敏感字段(密码、密钥)不记录到日志
---
**研究完成**: ✅ 所有技术不确定性已解决,可以进入设计阶段