diff --git "a/docs/plans/12-\345\205\250\345\261\200\345\223\215\345\272\224\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206\350\256\241\345\210\222.md" "b/docs/plans/12-\345\205\250\345\261\200\345\223\215\345\272\224\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206\350\256\241\345\210\222.md" index e38346a..f9cedb9 100644 --- "a/docs/plans/12-\345\205\250\345\261\200\345\223\215\345\272\224\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206\350\256\241\345\210\222.md" +++ "b/docs/plans/12-\345\205\250\345\261\200\345\223\215\345\272\224\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206\350\256\241\345\210\222.md" @@ -4,128 +4,177 @@ doc_no: "12" audience: - Dev - QA - - Ops -feature_area: global-response-exception-handling -purpose: "实现 PRD07 中的全局响应 envelope、异常处理器、错误模型和日志追踪计划。" +feature_area: global-response-error-handling +purpose: "实现 PRD07 中的全局 failure envelope、exception handlers、request id 集成和契约测试。" canonical_path: "docs/plans/12-全局响应与异常处理计划.md" status: draft version: "1.0.0" owner: "StephenQiu30" inputs: - "docs/prd/07-全局响应格式与异常处理.md" - - "docs/design/02-FastAPI后端结构重设计与规范化方案.md" + - "apps/api/app/core/errors.py" + - "apps/api/app/core/responses.py" + - "apps/api/app/middleware/request_context.py" outputs: - - "全局响应与异常处理实施计划" + - "全局响应与异常处理计划" triggers: - - "需要统一 API 错误响应" - - "需要新增全局 exception handler" - - "需要调整 OpenAPI 错误契约" + - "需要规范化全局错误处理" + - "需要集成 request id 到错误响应" downstream: - - "docs/acceptance/01-个人自部署万能视频下载器验收方案.md" - - "docs/operations/02-OpenAPI契约与前端生成协作.md" + - "openspec/changes/global-response-error-handling/" --- # PLAN12 全局响应与异常处理 ## 1. 背景 -PRD07 要求后端成功、失败、日志和 OpenAPI 使用统一错误语义。当前项目已有 `AppError` 和 failure envelope 基础,本计划负责把全局异常处理、request id 和错误模型收束成稳定工程能力。 +PRD07 定义了全局响应格式和异常处理的需求边界。当前已有初步实现(`AppError`、`ErrorCode`、`failure_response`),但缺少 request id 集成和统一契约测试。本计划将补全这些缺失,确保全局错误处理具备完整的可追踪性和可测试性。 ## 2. 目标 -1. 定义 shared error code 常量和错误响应 schema。 -2. 建立 FastAPI 全局 exception handlers。 -3. 为 request id 建立响应 meta 和日志链路。 -4. 保持既有主链路成功响应兼容。 -5. 用契约测试覆盖错误 envelope。 +1. 错误响应包含 `request_id` 字段,支持问题追踪。 +2. `request_id` 存储在 `request.state` 中,便于全局访问。 +3. 所有异常类型(`AppError`、`HTTPException`、`RequestValidationError`、未知异常)遵循统一 failure envelope。 +4. 契约测试覆盖所有错误路径。 ## 3. 非目标 -- 不一次性 envelope 化所有成功响应。 -- 不修改前端调用方式。 -- 不改变任务状态机和 Worker 主流程。 +- 不在本计划中迁移已有成功响应格式。 +- 不在本计划中添加新的错误码(仅使用已有 `ErrorCode`)。 +- 不在本计划中修改日志脱敏规则(已在 `unhandled_exception_handler` 中实现基础脱敏)。 ## 4. 核心内容 -### 4.1 文件边界 - -| 文件 | 职责 | -| --- | --- | -| `packages/shared/video_downloader_shared/error_codes.py` | 定义稳定错误码 | -| `apps/api/app/schemas/responses.py` | 定义 `ErrorResponse`、`ErrorBody`、`ResponseMeta` | -| `apps/api/app/core/errors.py` | 扩展 `AppError` 和领域异常基类 | -| `apps/api/app/core/responses.py` | 生成 success/failure envelope | -| `apps/api/app/core/exception_handlers.py` | 注册 FastAPI 全局异常处理器 | -| `apps/api/app/middleware/request_context.py` | 注入和传播 request id | -| `apps/api/app/main.py` | 挂载 middleware 与 exception handlers | -| `apps/api/tests/test_api_contract.py` | 覆盖错误 envelope 和未知异常 | -| `apps/api/tests/test_request_context_middleware.py` | 覆盖 request id | - -### 4.2 执行步骤 - -1. 新增失败测试:`AppError` 返回 `success=false/error/meta.request_id`。 -2. 新增失败测试:`RequestValidationError` 返回 `validation_error`。 -3. 新增失败测试:未知异常返回 `internal_error` 且不泄露原文。 -4. 新增 shared error code 常量。 -5. 新增响应 schema 和 response helper。 -6. 新增或收束全局 exception handlers。 -7. 调整 request context middleware,保证响应和日志能读取 request id。 -8. 更新 OpenAPI 契约测试,确认错误模型可见。 -9. 保持既有成功响应测试不变,避免兼容破坏。 - -### 4.3 测试命令 - -```bash -pytest apps/api/tests/test_api_contract.py -v -pytest apps/api/tests/test_request_context_middleware.py -v -pytest apps/api/tests/test_task_endpoints.py -v -pytest apps/api/tests/test_auth.py -v +### 4.1 Request ID 集成 + +**修改文件:** `apps/api/app/middleware/request_context.py` + +**变更内容:** + +1. 将 `request_id` 存储到 `request.state.request_id` +2. 保持现有响应头 `X-Request-ID` 逻辑不变 + +**实现步骤:** + +```python +# 在 call_next 之前添加 +request.state.request_id = request_id ``` -### 4.4 提交顺序 +### 4.2 Failure Envelope 增强 + +**修改文件:** `apps/api/app/core/responses.py` -1. `test:` 全局错误 envelope 和 request id 失败测试。 -2. `impl:` shared error code、response schema 和 exception handlers。 -3. `impl:` request id meta 和日志字段收束。 -4. `docs:` 同步 OpenAPI 协作和验收说明。 +**变更内容:** -## 5. 关联文档 +1. `failure_response` 函数新增 `request_id` 参数 +2. 错误响应结构包含 `request_id` 字段 -### 5.1 输入文档 +**实现步骤:** -1. `docs/prd/07-全局响应格式与异常处理.md` -2. `docs/design/02-FastAPI后端结构重设计与规范化方案.md` +```python +def failure_response(code: str, message: str, details: Any = None, request_id: str | None = None) -> dict[str, Any]: + error = { + "code": code, + "message": message, + "details": details, + } + if request_id: + error["request_id"] = request_id + return { + "success": False, + "error": error, + } +``` -### 5.2 输出文档 +### 4.3 Exception Handlers 更新 -1. `packages/shared/video_downloader_shared/error_codes.py` -2. `apps/api/app/schemas/responses.py` -3. `apps/api/app/core/exception_handlers.py` +**修改文件:** `apps/api/app/core/errors.py` -### 5.3 下游文档 +**变更内容:** + +1. 所有 exception handler 从 `request.state` 获取 `request_id` +2. 将 `request_id` 传递给 `failure_response` + +**实现步骤:** + +1. `app_error_handler`:获取 `request_id` 并传递 +2. `http_exception_handler`:获取 `request_id` 并传递 +3. `validation_exception_handler`:获取 `request_id` 并传递 +4. `unhandled_exception_handler`:获取 `request_id` 并传递 + +**示例:** + +```python +async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: + request_id = getattr(request.state, "request_id", None) + return JSONResponse( + status_code=exc.status_code, + content=failure_response(exc.code, exc.message, exc.details, request_id), + ) +``` -1. `docs/acceptance/01-个人自部署万能视频下载器验收方案.md` -2. `docs/operations/02-OpenAPI契约与前端生成协作.md` +### 4.4 契约测试 -## 6. 验收门禁 +**新增文件:** `apps/api/tests/test_error_contract.py` -- `AppError`、参数校验错误和未知异常返回统一 failure envelope。 -- 错误响应包含 `success=false`、`error.code`、`error.message`、`error.details` 和 `meta.request_id`。 -- 未知异常不向响应泄露内部异常原文。 -- 既有成功响应兼容测试通过。 -- 指定测试命令通过。 +**测试用例:** + +1. **AppError 测试** + - 验证 `AppError` 响应包含 `success: false` + - 验证响应包含 `error.code` 和 `error.message` + - 验证响应包含 `request_id` + +2. **HTTPException 测试** + - 验证 401/403/404/5xx 映射为标准错误码 + - 验证响应包含 `request_id` + +3. **RequestValidationError 测试** + - 验证参数校验错误返回 `validation_error` + - 验证 `details` 包含校验错误详情 + - 验证响应包含 `request_id` + +4. **未知异常测试** + - 验证未知异常返回 `internal_error` + - 验证不暴露内部堆栈 + - 验证响应包含 `request_id` + +5. **Request ID 来源测试** + - 验证优先使用客户端传入的 `X-Request-ID` + - 验证未传入时自动生成 UUID + +## 5. 任务拆解 + +### 5.1 实施任务 + +| 序号 | 任务 | 文件 | 说明 | +| --- | --- | --- | --- | +| 1 | Request ID 存储到 request.state | `middleware/request_context.py` | 在 middleware 中存储 request_id | +| 2 | failure_response 增加 request_id | `core/responses.py` | 函数签名和实现更新 | +| 3 | Exception handlers 传递 request_id | `core/errors.py` | 4 个 handler 更新 | +| 4 | 契约测试 | `tests/test_error_contract.py` | 新增测试文件 | + +### 5.2 验证任务 + +| 序号 | 验证项 | 命令 | 预期结果 | +| --- | --- | --- | --- | +| 1 | 单元测试 | `pytest apps/api/tests/test_error_contract.py -v` | 全部通过 | +| 2 | 集成测试 | `pytest apps/api/tests/ -v` | 无回归 | +| 3 | 仓库验证 | `bash scripts/validate-repository.sh` | 通过 | -## 7. 风险与边界 +## 6. 风险与边界 -如果全量切换成功响应 envelope,调用方需要同步调整。该计划只要求错误响应统一和新增 API 优先 envelope 化。 +- **风险**:修改 `failure_response` 签名可能影响已有调用 → **缓解**:`request_id` 参数默认值为 `None`,保持向后兼容 +- **风险**:`request.state` 在某些异常场景下可能不存在 → **缓解**:使用 `getattr(request.state, "request_id", None)` 安全访问 -## 8. 待确认问题 +## 7. 依赖关系 -- 是否为 `details` 建立平台级字段白名单。 -- 是否在 Nginx 或网关层透传外部 request id。 +- 依赖 `apps/api/app/core/errors.py` 已有实现 +- 依赖 `apps/api/app/core/responses.py` 已有实现 +- 依赖 `apps/api/app/middleware/request_context.py` 已有实现 -## 9. 变更记录 +## 8. 变更记录 | 日期 | 作者 | 版本 | 变更说明 | | --- | --- | --- | --- | -| 2026-06-16 | StephenQiu30 | 1.0.0 | 初始化 PLAN12 | +| 2026-06-16 | StephenQiu30 | 1.0.0 | 创建 PLAN12:全局响应与异常处理计划 | diff --git "a/docs/prd/07-\345\205\250\345\261\200\345\223\215\345\272\224\346\240\274\345\274\217\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206.md" "b/docs/prd/07-\345\205\250\345\261\200\345\223\215\345\272\224\346\240\274\345\274\217\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206.md" index 99f059d..98df239 100644 --- "a/docs/prd/07-\345\205\250\345\261\200\345\223\215\345\272\224\346\240\274\345\274\217\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206.md" +++ "b/docs/prd/07-\345\205\250\345\261\200\345\223\215\345\272\224\346\240\274\345\274\217\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206.md" @@ -5,161 +5,195 @@ audience: - PM - Dev - QA - - Ops -feature_area: global-response-exception-handling -purpose: "定义后端 API 的全局响应格式、全局异常处理、错误码和可观测性边界。" +feature_area: global-response-error-handling +purpose: "定义 video-server 后端全局响应格式和异常处理需求边界,使 API 错误响应、错误码、request id 和日志脱敏具备统一契约。" canonical_path: "docs/prd/07-全局响应格式与异常处理.md" status: draft version: "1.0.0" owner: "StephenQiu30" inputs: - - "docs/design/02-FastAPI后端结构重设计与规范化方案.md" - "docs/prd/01-解析入口与URL安全.md" - "docs/prd/03-异步下载任务主链路.md" + - "apps/api/app/core/errors.py" + - "apps/api/app/core/responses.py" outputs: - - "全局响应与异常处理产品边界" - - "错误语义验收标准" + - "全局 failure envelope 契约" + - "异常分类与错误码规范" + - "日志脱敏要求" triggers: - - "新增 API 响应模型" - - "调整异常处理或错误码" - - "修改 OpenAPI 错误契约" + - "新增错误码" + - "调整 failure envelope 结构" + - "修改异常处理逻辑" + - "调整日志脱敏规则" downstream: - "docs/plans/12-全局响应与异常处理计划.md" - - "docs/acceptance/01-个人自部署万能视频下载器验收方案.md" + - "openspec/changes/global-response-error-handling/" --- # PRD07 全局响应格式与异常处理 ## 1. 背景 -视频下载后端会面对 URL 校验失败、鉴权失败、平台限制、格式不可用、队列不可用、对象存储失败和未知异常等多种错误。如果每个接口自由返回错误结构,调用方、QA 和运维都很难稳定判断问题类型。 +video-server 后端已有初步的错误处理实现(`AppError`、`ErrorCode`、`failure_response`),但缺少统一的需求边界定义。当前问题: -本 PRD 定义后端全局响应格式和异常处理边界,使成功响应、失败响应、日志和 OpenAPI 契约保持一致。 +1. 错误响应格式虽已存在,但缺少明确的契约文档。 +2. Request ID 已在 middleware 中生成,但未包含在错误响应中,难以追踪。 +3. 日志脱敏规则未明确定义,存在泄露敏感信息风险。 +4. 成功响应格式不统一,部分接口直接返回数据,部分使用 envelope。 ## 2. 目标 -1. 所有业务错误都返回统一 failure envelope。 -2. 所有错误码来自 shared 常量或领域异常,不允许随意散落字符串。 -3. 未知异常对用户隐藏内部细节,对日志保留可排查信息。 -4. 所有请求都具备 request id,方便 API、Worker 和日志串联排查。 -5. 成功响应 envelope 化采用兼容迁移策略,避免一次性破坏现有主链路契约。 +1. 建立统一的 failure envelope 契约,所有错误响应遵循相同结构。 +2. 定义错误码分类和使用规范,确保错误语义稳定、可机器读取。 +3. 将 Request ID 集成到错误响应中,支持问题追踪。 +4. 定义日志脱敏规则,防止泄露 token、cookie、secret、密码等敏感信息。 +5. 明确成功响应迁移原则,保持向后兼容。 ```gherkin -Given 后端任意接口发生业务错误或系统异常 -When 全局异常处理器接管响应 -Then 调用方收到统一 failure envelope -And 日志中包含 request_id、错误码和安全的排查信息 +Given API 遇到任何错误情况 +When 系统生成错误响应 +Then 响应遵循统一 failure envelope 结构 +And 包含 request_id 用于追踪 +And 日志中不包含敏感信息 ``` ## 3. 非目标 -- 不要求本轮一次性把所有既有成功响应改成 envelope。 -- 不定义前端错误展示样式。 -- 不把内部异常堆栈、token、cookie、secret 或预签名 URL 暴露给用户。 -- 不替代 Worker 任务事件;任务事件仍用于任务生命周期记录。 +- 不在本 PRD 中一次性 envelope 化所有既有成功响应。 +- 不在本 PRD 中定义前端错误展示逻辑。 +- 不在本 PRD 中重写任务状态机。 +- 不向用户暴露内部异常堆栈、token、cookie、secret、密码或完整预签名 URL。 ## 4. 核心内容 -### 4.1 错误响应格式 +### 4.1 Failure Envelope 契约 -所有异常响应必须使用统一结构: +所有错误响应必须遵循以下结构: ```json { "success": false, "error": { - "code": "format_unavailable", - "message": "该视频源未提供所选清晰度,请选择推荐下载或其他可用清晰度后重试。", - "details": null - }, - "meta": { - "request_id": "req_..." + "code": "error_code", + "message": "用户可读的错误描述", + "details": {}, + "request_id": "abc123" } } ``` +**字段说明:** + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `success` | boolean | 是 | 固定为 `false` | +| `error.code` | string | 是 | 机器可读错误码,使用 snake_case | +| `error.message` | string | 是 | 用户可读的错误描述,中文 | +| `error.details` | object/array/null | 否 | 额外错误详情,用于参数校验等场景 | +| `error.request_id` | string | 是 | 请求追踪 ID,来自 `X-Request-ID` | + ### 4.2 成功响应迁移原则 -成功响应目标结构: +**向后兼容策略:** + +1. 新接口必须使用统一 envelope:`{"success": true, "data": {...}}` +2. 已有接口保持原格式,不在本 PRD 中强制迁移 +3. 后续 PRD/PLAN 可按需逐步迁移已有接口 + +**统一成功 envelope 结构:** ```json { "success": true, - "data": {}, - "meta": { - "request_id": "req_...", - "timestamp": "2026-06-16T12:00:00Z" - } + "data": {} } ``` -迁移原则: +### 4.3 异常分类与错误码 -1. 新增 API 优先使用成功 envelope。 -2. 既有主链路 API 可以先保持现有 schema,降低兼容风险。 -3. 全量 envelope 化必须先更新 OpenAPI、契约测试和运行协作文档。 +**错误码分类:** + +| 分类 | 前缀 | 示例 | 说明 | +| --- | --- | --- | --- | +| 入口校验 | - | `invalid_url`, `unsafe_url` | URL 格式和安全校验 | +| 平台相关 | - | `parse_failed`, `platform_restricted` | 平台识别和解析 | +| 基础设施 | - | `engine_unavailable`, `queue_unavailable` | 依赖服务不可用 | +| 认证授权 | - | `invalid_credentials`, `user_disabled` | 用户认证和权限 | +| 任务生命周期 | - | `invalid_state`, `not_found` | 任务状态和查找 | +| 通用 | - | `rate_limited`, `validation_error` | 通用错误 | -### 4.3 异常分类 +**异常处理分类:** -| 异常类别 | HTTP 状态码 | 错误码 | +| 异常类型 | HTTP 状态码 | 处理方式 | | --- | --- | --- | -| 参数校验失败 | 422 | `validation_error` | -| 未登录 | 401 | `unauthorized` | -| 无权限 | 403 | `forbidden` | -| 资源不存在 | 404 | `not_found` | -| 状态不允许 | 409 | `invalid_state` | -| 请求限流 | 429 | `rate_limited` | -| 配额超限 | 429 | `limit_exceeded` | -| 队列不可用 | 503 | `queue_unavailable` | -| 存储不可用 | 503 | `storage_unavailable` | -| 平台暂不可用 | 503 | `platform_unavailable` | -| 未知异常 | 500 | `internal_error` | - -### 4.4 日志与脱敏 - -1. 日志必须包含 `request_id`。 -2. 下载任务相关日志必须包含 `task_id`、`user_id` 和 `stage`。 -3. 日志不得记录完整 token、cookie、secret、密码和预签名 URL。 -4. 5xx 日志记录 traceback,响应只返回安全文案。 -5. 平台异常记录 extractor、source_id 和摘要,不直接返回外部异常原文。 +| `AppError` | 自定义 | 使用 `error.code` 和 `error.message` | +| `HTTPException` | 原状态码 | 映射为标准错误码 | +| `RequestValidationError` | 422 | `validation_error` + 详细校验信息 | +| 未知异常 | 500 | `internal_error` + 通用提示 | + +### 4.4 Request ID 集成 + +**要求:** + +1. 每个请求必须有唯一 `request_id`。 +2. 优先使用客户端传入的 `X-Request-ID`,否则生成 UUID。 +3. `request_id` 必须存储在 `request.state` 中,便于全局访问。 +4. 错误响应必须包含 `request_id`。 +5. 响应头必须包含 `X-Request-ID`。 + +### 4.5 日志脱敏要求 + +**禁止记录的敏感信息:** + +1. 用户 token、session、cookie +2. 密码、密钥、secret +3. 完整预签名 URL(包含签名参数) +4. 用户个人信息(邮箱、手机号等) + +**日志格式要求:** + +1. 异常日志必须包含 `request_id`、`path`、`method` +2. 未知异常必须记录完整堆栈(`exc_info=exc`) +3. 已知业务异常只记录错误码和消息,不记录堆栈 ## 5. 关联文档 ### 5.1 输入文档 -1. `docs/design/02-FastAPI后端结构重设计与规范化方案.md` -2. `docs/prd/01-解析入口与URL安全.md` -3. `docs/prd/03-异步下载任务主链路.md` +1. `docs/prd/01-解析入口与URL安全.md` +2. `docs/prd/03-异步下载任务主链路.md` +3. `apps/api/app/core/errors.py` +4. `apps/api/app/core/responses.py` ### 5.2 输出文档 1. `docs/plans/12-全局响应与异常处理计划.md` +2. `openspec/changes/global-response-error-handling/` ### 5.3 下游文档 -1. `docs/acceptance/01-个人自部署万能视频下载器验收方案.md` -2. `docs/operations/02-OpenAPI契约与前端生成协作.md` +1. 后续 PRD/PLAN 可基于本 PRD 扩展错误码和 envelope 使用 ## 6. 验收门禁 -- `AppError`、参数校验错误和未知异常都返回统一 failure envelope。 -- 错误响应包含稳定 `code` 和可展示 `message`。 -- 未知异常不泄露内部错误原文。 -- request id 能出现在响应 meta 和日志中。 -- OpenAPI 能表达全局错误模型。 +- 所有错误响应遵循统一 failure envelope 结构。 +- 错误响应包含 `request_id` 字段。 +- 未知异常不暴露内部堆栈、token、cookie、secret。 +- 日志中不包含敏感信息。 +- `AppError`、参数校验错误和未知异常都有统一处理。 ## 7. 风险与边界 -成功响应 envelope 化会影响已有调用方,必须分阶段迁移。错误响应可以先统一,因为当前系统已经使用 failure envelope 基础结构。 +- **风险**:修改 failure envelope 结构可能影响已有客户端 → **缓解**:保持 `success` 和 `error` 字段兼容,仅新增 `request_id` +- **风险**:日志脱敏过严可能影响排障 → **缓解**:保留 request_id 和错误码,便于关联分析 ## 8. 待确认问题 -- 是否要求所有成功响应在同一个版本窗口内统一 envelope 化。 -- 是否为错误响应 `details` 定义稳定字段白名单。 -- 是否将 request id 同步写入 Worker 任务事件。 +- 是否需要为不同环境(dev/staging/prod)配置不同的日志详细程度? +- 是否需要支持错误码国际化? ## 9. 变更记录 | 日期 | 作者 | 版本 | 变更说明 | | --- | --- | --- | --- | -| 2026-06-16 | StephenQiu30 | 1.0.0 | 初始化 PRD07:全局响应格式与异常处理 | +| 2026-06-16 | StephenQiu30 | 1.0.0 | 创建 PRD07:全局响应格式与异常处理 | diff --git a/openspec/changes/global-response-error-handling/design.md b/openspec/changes/global-response-error-handling/design.md new file mode 100644 index 0000000..008207f --- /dev/null +++ b/openspec/changes/global-response-error-handling/design.md @@ -0,0 +1,76 @@ +## Context + +video-server 后端使用 FastAPI 框架,已有初步的错误处理实现: + +- **异常类**:`AppError` — 自定义业务异常,包含 `code`、`message`、`status_code`、`details` +- **错误码**:`ErrorCode` — StrEnum 枚举,分类定义错误码 +- **响应格式**:`failure_response` — 生成统一错误响应结构 +- **异常处理器**: + - `app_error_handler` — 处理 `AppError` + - `http_exception_handler` — 处理 `HTTPException` 和 `StarletteHTTPException` + - `validation_exception_handler` — 处理 `RequestValidationError` + - `unhandled_exception_handler` — 处理未知异常 +- **Request Context**:`request_context_middleware` — 生成/提取 request_id,添加响应头 + +**当前缺失:** + +1. request_id 未存储到 `request.state`,exception handler 无法访问 +2. 错误响应未包含 request_id +3. 缺少契约测试覆盖所有错误路径 + +## Goals / Non-Goals + +**Goals:** + +- 错误响应包含 request_id,支持问题追踪 +- request_id 存储在 request.state 中,便于全局访问 +- 所有异常类型遵循统一 failure envelope +- 契约测试覆盖所有错误路径 + +**Non-Goals:** + +- 不迁移已有成功响应格式 +- 不添加新的错误码 +- 不修改日志脱敏规则(已实现基础脱敏) + +## Decisions + +### 1. Request ID 存储在 request.state + +**选择**:在 `request_context_middleware` 中将 `request_id` 存储到 `request.state.request_id`。 + +**理由**:`request.state` 是 FastAPI/Starlette 提供的请求级状态存储,exception handler 可以通过 `request` 参数访问。 + +**替代方案**: +- 使用 contextvars:增加复杂度,且 exception handler 已有 `request` 参数 +- 在 exception handler 中重新生成 request_id:丢失客户端传入的 request_id + +### 2. failure_response 函数增加 request_id 参数 + +**选择**:`failure_response(code, message, details=None, request_id=None)`,request_id 默认值为 None。 + +**理由**: +- 保持向后兼容,已有调用无需修改 +- 显式传递 request_id,避免函数内部访问 request.state(违反单一职责) + +### 3. Exception handler 从 request.state 获取 request_id + +**选择**:使用 `getattr(request.state, "request_id", None)` 安全访问。 + +**理由**: +- `request.state` 在某些异常场景下可能不存在属性 +- 安全访问避免二次异常 + +### 4. 错误响应结构保持向后兼容 + +**选择**:在 `error` 对象中新增 `request_id` 字段,不修改已有字段。 + +**理由**: +- 已有客户端依赖 `success`、`error.code`、`error.message`、`error.details` +- 新增字段向后兼容,客户端可选择性使用 + +## Risks / Trade-offs + +- **风险**:修改 `failure_response` 签名可能影响已有调用 → **缓解**:`request_id` 参数默认值为 `None`,保持向后兼容 +- **风险**:`request.state` 在某些异常场景下可能不存在 → **缓解**:使用 `getattr` 安全访问 +- **风险**:契约测试可能与实际实现不一致 → **缓解**:测试直接调用 exception handler,验证实际响应 diff --git a/openspec/changes/global-response-error-handling/proposal.md b/openspec/changes/global-response-error-handling/proposal.md new file mode 100644 index 0000000..5bab060 --- /dev/null +++ b/openspec/changes/global-response-error-handling/proposal.md @@ -0,0 +1,37 @@ +## Why + +video-server 后端已有初步的错误处理实现(`AppError`、`ErrorCode`、`failure_response`),但缺少统一的契约定义和 request id 集成。当前问题: + +1. 错误响应格式虽已存在,但缺少明确的契约文档和 OpenSpec 规范。 +2. Request ID 已在 middleware 中生成,但未包含在错误响应中,难以追踪。 +3. 缺少契约测试覆盖所有错误路径。 + +需要通过 OpenSpec 将全局错误模型、exception handlers、request id 集成规范化,为后续 API 稳定性和可观测性提供基础。 + +## What Changes + +- 新增 OpenSpec spec `global-error-model`:定义 failure envelope 契约、错误码分类、成功响应迁移原则 +- 新增 OpenSpec spec `exception-handlers`:定义各类异常的处理方式和响应格式 +- 新增 OpenSpec spec `request-id`:定义 request id 的生成、存储、传递和使用规范 +- 修改 `apps/api/app/middleware/request_context.py`:将 request_id 存储到 request.state +- 修改 `apps/api/app/core/responses.py`:failure_response 函数增加 request_id 参数 +- 修改 `apps/api/app/core/errors.py`:所有 exception handler 传递 request_id +- 新增 `apps/api/tests/test_error_contract.py`:契约测试覆盖所有错误路径 + +## Capabilities + +### New Capabilities + +- `global-error-model`: 全局错误模型规范,覆盖 failure envelope 结构、错误码分类、成功响应迁移原则 +- `exception-handlers`: 异常处理器规范,覆盖 AppError、HTTPException、RequestValidationError、未知异常的处理方式 +- `request-id`: Request ID 规范,覆盖生成、存储、传递、响应包含和日志记录 + +### Modified Capabilities + +(无已有 spec 需要修改) + +## Impact + +- 受影响代码:`apps/api/app/middleware/request_context.py`、`apps/api/app/core/responses.py`、`apps/api/app/core/errors.py` +- 受影响测试:`apps/api/tests/test_error_contract.py`(新增) +- 受影响文档:`docs/prd/07-全局响应格式与异常处理.md`、`docs/plans/12-全局响应与异常处理计划.md` diff --git a/openspec/changes/global-response-error-handling/specs/exception-handlers/spec.md b/openspec/changes/global-response-error-handling/specs/exception-handlers/spec.md new file mode 100644 index 0000000..9a46bfc --- /dev/null +++ b/openspec/changes/global-response-error-handling/specs/exception-handlers/spec.md @@ -0,0 +1,177 @@ +--- +layer: Spec +spec_id: exception-handlers +audience: + - Dev + - QA +purpose: "定义各类异常的处理方式和响应格式,确保所有异常遵循统一 failure envelope。" +canonical_path: "openspec/changes/global-response-error-handling/specs/exception-handlers/spec.md" +status: draft +version: "1.0.0" +owner: "StephenQiu30" +inputs: + - "docs/prd/07-全局响应格式与异常处理.md" + - "apps/api/app/core/errors.py" +outputs: + - "异常处理器规范" +triggers: + - "新增异常类型" + - "调整异常处理逻辑" +--- + +# Spec: exception-handlers + +## 1. 异常处理分类 + +### 1.1 AppError 处理 + +**触发条件**:业务逻辑抛出 `AppError` 异常 + +**处理方式**: + +```python +async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: + request_id = getattr(request.state, "request_id", None) + return JSONResponse( + status_code=exc.status_code, + content=failure_response(exc.code, exc.message, exc.details, request_id), + ) +``` + +**响应格式**: + +```json +{ + "success": false, + "error": { + "code": "app_error_code", + "message": "业务错误描述", + "details": {}, + "request_id": "abc123" + } +} +``` + +**约束**: +- 使用 `exc.code` 作为 `error.code` +- 使用 `exc.message` 作为 `error.message` +- 使用 `exc.details` 作为 `error.details` +- 使用 `exc.status_code` 作为 HTTP 状态码 + +### 1.2 HTTPException 处理 + +**触发条件**:FastAPI 或 Starlette 抛出 `HTTPException` + +**处理方式**: + +```python +async def http_exception_handler(request: Request, exc: HTTPException | StarletteHTTPException) -> JSONResponse: + request_id = getattr(request.state, "request_id", None) + code, message = _http_error_contract(exc) + return JSONResponse(status_code=exc.status_code, content=failure_response(code, message, request_id=request_id)) +``` + +**错误码映射**: + +| HTTP 状态码 | error.code | error.message | +| --- | --- | --- | +| 401 | `unauthorized` | 请先登录后再继续操作 | +| 403 | `forbidden` | 当前账号没有权限执行该操作 | +| 404 | `not_found` | 资源不存在 | +| 5xx | `internal_error` | 服务暂时不可用,请稍后重试 | +| 其他 | `http_error` | 原 detail 内容或 "请求处理失败" | + +### 1.3 RequestValidationError 处理 + +**触发条件**:FastAPI 参数校验失败 + +**处理方式**: + +```python +async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: + request_id = getattr(request.state, "request_id", None) + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=failure_response("validation_error", "请求参数不符合要求", exc.errors(), request_id), + ) +``` + +**响应格式**: + +```json +{ + "success": false, + "error": { + "code": "validation_error", + "message": "请求参数不符合要求", + "details": [ + { + "loc": ["body", "field_name"], + "msg": "field required", + "type": "value_error" + } + ], + "request_id": "abc123" + } +} +``` + +### 1.4 未知异常处理 + +**触发条件**:未被捕获的异常 + +**处理方式**: + +```python +async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + request_id = getattr(request.state, "request_id", None) + logger.exception( + "Unhandled API exception path=%s method=%s request_id=%s", + request.url.path, + request.method, + request_id, + exc_info=exc, + ) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=failure_response("internal_error", "服务暂时不可用,请稍后重试", request_id=request_id), + ) +``` + +**响应格式**: + +```json +{ + "success": false, + "error": { + "code": "internal_error", + "message": "服务暂时不可用,请稍后重试", + "request_id": "abc123" + } +} +``` + +**约束**: +- 不得在响应中暴露内部异常堆栈 +- 必须记录完整堆栈到日志(`exc_info=exc`) +- 日志必须包含 `request_id`、`path`、`method` + +## 2. 实现要求 + +### 2.1 所有 handler 必须从 request.state 获取 request_id + +```python +request_id = getattr(request.state, "request_id", None) +``` + +### 2.2 所有 handler 必须传递 request_id 给 failure_response + +```python +content=failure_response(code, message, details, request_id) +``` + +### 2.3 约束 + +- 不得修改已有错误码映射逻辑 +- 不得修改已有 HTTP 状态码逻辑 +- 不得在响应中暴露敏感信息 diff --git a/openspec/changes/global-response-error-handling/specs/global-error-model/spec.md b/openspec/changes/global-response-error-handling/specs/global-error-model/spec.md new file mode 100644 index 0000000..41af5ea --- /dev/null +++ b/openspec/changes/global-response-error-handling/specs/global-error-model/spec.md @@ -0,0 +1,119 @@ +--- +layer: Spec +spec_id: global-error-model +audience: + - Dev + - QA +purpose: "定义全局错误模型规范,覆盖 failure envelope 结构、错误码分类、成功响应迁移原则。" +canonical_path: "openspec/changes/global-response-error-handling/specs/global-error-model/spec.md" +status: draft +version: "1.0.0" +owner: "StephenQiu30" +inputs: + - "docs/prd/07-全局响应格式与异常处理.md" +outputs: + - "全局错误模型规范" +triggers: + - "新增错误码" + - "调整 failure envelope 结构" +--- + +# Spec: global-error-model + +## 1. Failure Envelope 结构 + +所有错误响应必须遵循以下结构: + +```json +{ + "success": false, + "error": { + "code": "error_code", + "message": "用户可读的错误描述", + "details": {}, + "request_id": "abc123" + } +} +``` + +### 字段定义 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `success` | boolean | 是 | 固定为 `false` | +| `error` | object | 是 | 错误详情对象 | +| `error.code` | string | 是 | 机器可读错误码,使用 snake_case | +| `error.message` | string | 是 | 用户可读的错误描述,中文 | +| `error.details` | object/array/null | 否 | 额外错误详情,用于参数校验等场景 | +| `error.request_id` | string | 是 | 请求追踪 ID,来自 `X-Request-ID` | + +## 2. 错误码分类 + +| 分类 | 错误码 | HTTP 状态码 | 说明 | +| --- | --- | --- | --- | +| 入口校验 | `invalid_url` | 400 | URL 格式错误 | +| 入口校验 | `unsafe_url` | 400 | URL 不安全(SSRF 风险) | +| 平台相关 | `parse_failed` | 400 | 平台解析失败 | +| 平台相关 | `platform_restricted` | 403 | 平台限制 | +| 平台相关 | `platform_rate_limited` | 429 | 平台限流 | +| 平台相关 | `unsupported_platform` | 400 | 不支持的平台 | +| 平台相关 | `platform_unavailable` | 503 | 平台不可用 | +| 基础设施 | `engine_unavailable` | 503 | 下载引擎不可用 | +| 基础设施 | `queue_unavailable` | 503 | 队列不可用 | +| 基础设施 | `storage_unavailable` | 503 | 存储不可用 | +| 认证授权 | `invalid_credentials` | 401 | 凭据错误 | +| 认证授权 | `user_disabled` | 403 | 用户已禁用 | +| 认证授权 | `auth_locked` | 403 | 认证锁定 | +| 认证授权 | `registration_disabled` | 403 | 注册未开放 | +| 认证授权 | `registration_failed` | 400 | 注册失败 | +| 任务生命周期 | `invalid_state` | 409/422 | 状态无效 | +| 任务生命周期 | `limit_exceeded` | 429 | 超出限制 | +| 任务生命周期 | `not_found` | 404 | 资源不存在 | +| 任务生命周期 | `retention_expired` | 410 | 保留时间已过期 | +| 任务生命周期 | `retry_superseded` | 409 | 重试已被取代 | +| 通用 | `rate_limited` | 429 | 请求限流 | +| 通用 | `validation_error` | 422 | 参数校验错误 | +| 通用 | `internal_error` | 500 | 内部错误 | + +## 3. 成功响应迁移原则 + +### 向后兼容策略 + +1. 新接口必须使用统一 envelope:`{"success": true, "data": {...}}` +2. 已有接口保持原格式,不强制迁移 +3. 后续可按需逐步迁移已有接口 + +### 统一成功 envelope 结构 + +```json +{ + "success": true, + "data": {} +} +``` + +## 4. 实现要求 + +### 4.1 failure_response 函数 + +```python +def failure_response(code: str, message: str, details: Any = None, request_id: str | None = None) -> dict[str, Any]: + error = { + "code": code, + "message": message, + "details": details, + } + if request_id: + error["request_id"] = request_id + return { + "success": False, + "error": error, + } +``` + +### 4.2 约束 + +- `error.code` 必须使用 snake_case +- `error.message` 必须是用户可读的中文描述 +- `error.request_id` 必须包含在所有错误响应中 +- 不得在 `error.details` 中暴露内部异常堆栈、token、cookie、secret、密码 diff --git a/openspec/changes/global-response-error-handling/specs/request-id/spec.md b/openspec/changes/global-response-error-handling/specs/request-id/spec.md new file mode 100644 index 0000000..b3b23ad --- /dev/null +++ b/openspec/changes/global-response-error-handling/specs/request-id/spec.md @@ -0,0 +1,144 @@ +--- +layer: Spec +spec_id: request-id +audience: + - Dev + - QA +purpose: "定义 request id 的生成、存储、传递和使用规范,确保请求追踪的完整性和一致性。" +canonical_path: "openspec/changes/global-response-error-handling/specs/request-id/spec.md" +status: draft +version: "1.0.0" +owner: "StephenQiu30" +inputs: + - "docs/prd/07-全局响应格式与异常处理.md" + - "apps/api/app/middleware/request_context.py" +outputs: + - "Request ID 规范" +triggers: + - "调整 request id 生成逻辑" + - "修改 request id 传递方式" +--- + +# Spec: request-id + +## 1. Request ID 生命周期 + +### 1.1 生成 + +**来源优先级**: + +1. 客户端传入的 `X-Request-ID` 请求头 +2. 自动生成的 UUID(`uuid4().hex`) + +**实现**: + +```python +request_id = request.headers.get("X-Request-ID") or uuid4().hex +``` + +**约束**: +- 客户端传入的 `X-Request-ID` 必须是非空字符串 +- 自动生成的 UUID 使用 `uuid4().hex` 格式(32 位十六进制,无连字符) + +### 1.2 存储 + +**存储位置**:`request.state.request_id` + +**实现**: + +```python +request.state.request_id = request_id +``` + +**约束**: +- 必须在 `call_next` 之前存储 +- 存储后可在整个请求生命周期内访问 + +### 1.3 传递 + +**响应头**:`X-Request-ID` + +**实现**: + +```python +response.headers["X-Request-ID"] = request_id +``` + +**约束**: +- 所有响应必须包含 `X-Request-ID` 响应头 +- 响应头值必须与请求处理使用的 request_id 一致 + +### 1.4 错误响应包含 + +**位置**:`error.request_id` + +**实现**: + +```python +failure_response(code, message, details, request_id) +``` + +**约束**: +- 所有错误响应必须包含 `request_id` 字段 +- `request_id` 位于 `error` 对象内 + +## 2. 使用场景 + +### 2.1 异常处理 + +所有 exception handler 必须从 `request.state` 获取 `request_id`: + +```python +request_id = getattr(request.state, "request_id", None) +``` + +### 2.2 日志记录 + +异常日志必须包含 `request_id`: + +```python +logger.exception( + "Unhandled API exception path=%s method=%s request_id=%s", + request.url.path, + request.method, + request_id, + exc_info=exc, +) +``` + +### 2.3 业务逻辑 + +业务逻辑可通过 `request.state.request_id` 访问 request_id,用于日志记录或追踪。 + +## 3. 实现要求 + +### 3.1 Middleware 修改 + +**文件**:`apps/api/app/middleware/request_context.py` + +**变更**: + +```python +async def request_context_middleware( + request: Request, + call_next: Callable[[Request], Awaitable[Response]], +) -> Response: + request_id = request.headers.get("X-Request-ID") or uuid4().hex + request.state.request_id = request_id # 新增 + start = time.perf_counter() + response = await call_next(request) + duration_ms = int((time.perf_counter() - start) * 1000) + + response.headers["X-Request-ID"] = request_id + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Request-Duration-Ms"] = str(duration_ms) + return response +``` + +### 3.2 约束 + +- 不得修改 request_id 生成逻辑 +- 不得修改响应头设置逻辑 +- 不得在 request_id 中包含敏感信息 diff --git a/openspec/changes/global-response-error-handling/tasks.md b/openspec/changes/global-response-error-handling/tasks.md new file mode 100644 index 0000000..b1b7e4e --- /dev/null +++ b/openspec/changes/global-response-error-handling/tasks.md @@ -0,0 +1,51 @@ +## 1. OpenSpec Change Artifacts + +- [ ] 1.1 创建 `openspec/changes/global-response-error-handling/proposal.md` +- [ ] 1.2 创建 `openspec/changes/global-response-error-handling/design.md` +- [ ] 1.3 创建 `openspec/changes/global-response-error-handling/specs/global-error-model/spec.md` +- [ ] 1.4 创建 `openspec/changes/global-response-error-handling/specs/exception-handlers/spec.md` +- [ ] 1.5 创建 `openspec/changes/global-response-error-handling/specs/request-id/spec.md` +- [ ] 1.6 创建 `openspec/changes/global-response-error-handling/tasks.md` + +## 2. Request ID 集成 + +- [ ] 2.1 修改 `apps/api/app/middleware/request_context.py`,将 request_id 存储到 request.state +- [ ] 2.2 验证 request_id 在 request.state 中可用 + +## 3. Failure Envelope 增强 + +- [ ] 3.1 修改 `apps/api/app/core/responses.py`,failure_response 函数增加 request_id 参数 +- [ ] 3.2 验证 failure_response 返回包含 request_id 的响应 + +## 4. Exception Handlers 更新 + +- [ ] 4.1 修改 `app_error_handler`,从 request.state 获取 request_id 并传递 +- [ ] 4.2 修改 `http_exception_handler`,从 request.state 获取 request_id 并传递 +- [ ] 4.3 修改 `validation_exception_handler`,从 request.state 获取 request_id 并传递 +- [ ] 4.4 修改 `unhandled_exception_handler`,从 request.state 获取 request_id 并传递 + +## 5. 契约测试 + +- [ ] 5.1 创建 `apps/api/tests/test_error_contract.py` +- [ ] 5.2 编写 AppError 契约测试 +- [ ] 5.3 编写 HTTPException 契约测试 +- [ ] 5.4 编写 RequestValidationError 契约测试 +- [ ] 5.5 编写未知异常契约测试 +- [ ] 5.6 编写 Request ID 来源测试 + +## 6. 推广 Specs 到 Baseline + +- [ ] 6.1 推广 `global-error-model` spec 到 `openspec/specs/global-error-model/spec.md` +- [ ] 6.2 推广 `exception-handlers` spec 到 `openspec/specs/exception-handlers/spec.md` +- [ ] 6.3 推广 `request-id` spec 到 `openspec/specs/request-id/spec.md` + +## 7. 验证 + +- [ ] 7.1 运行 `pytest apps/api/tests/test_error_contract.py -v`,全部通过 +- [ ] 7.2 运行 `pytest apps/api/tests/ -v`,无回归 +- [ ] 7.3 运行 `bash scripts/validate-repository.sh`,通过 +- [ ] 7.4 审阅 PRD07、PLAN12 与设计文档,确认一致性 + +## 8. 文档更新 + +- [ ] 8.1 更新 `docs/plans/12-全局响应与异常处理计划.md` 状态从 `draft` 到 `accepted`