# 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 错误返回通用消息
- 日志访问受限（仅运维团队）
- 堆栈跟踪仅记录到日志，不返回给客户端
- 敏感字段（密码、密钥）不记录到日志

---

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