docs/dev-guides/7-unified-error-handling.md
维护者: DF 核心团队 最后更新: 2026-05-01 适用范围: 后端 API route、流式端点、前端 API 调用、错误码/i18n、错误相关测试 核心原则: 业务/校验错误一律 HTTP 200,仅认证/授权和不可控传输错误使用非 200
所有新建或重构的 DF API 必须先按响应类型选择协议,不要混用:
| 场景 | HTTP 状态码 | 响应格式 | 说明 |
|---|---|---|---|
| 非流式成功 | 200 | {"status": "success", "data": ...} | 使用 json_ok(data) |
| 非流式业务/校验错误 | 200 | {"status": "error", "error": {"code", "message", "retry"}} | 使用 raise AppError(...) |
| 非流式认证/授权错误 | 401 / 403 | {"status": "error", "error": {"code", "message", "retry"}} | 仅 AUTH_REQUIRED / AUTH_EXPIRED / ACCESS_DENIED |
| 流式预检成功 | 200 | application/x-ndjson 事件流 | 进入 generator 前完成校验 |
| 流式预检错误 | 200 | application/json + {"status": "error", "error": ...} | 使用 stream_preflight_error(AppError(...)) |
| 流式运行中错误 | 200 | NDJSON 行:{"type": "error", "error": ...} | 使用 stream_error_event(...);流已建立后不能再改 HTTP 状态或返回整体 JSON body |
| 流式运行中警告 | 200 | NDJSON 行:{"type": "warning", "warning": ...} | 使用 stream_warning_event(...) 或 collect_stream_warning();非致命,流继续 |
| 无匹配 Flask route | 404 | /api/ 返回 JSON error;非 API 可能走 SPA fallback | 由 Flask/global handler 处理 |
| 请求体过大 | 413 | JSON error | 由 WSGI/Flask handler 处理 |
| 未捕获异常 | 500 | JSON error | 表示程序 bug 或意外服务端异常 |
禁止事项:
400/422 表达业务校验错误;使用 HTTP 200 + status: "error"。{"status": "error", ...};流事件必须靠 type 区分。str(exc)、secret、token、连接串、文件系统敏感路径或堆栈。开发新 route 或修改错误处理时,对照此表选择工具。所有工具定义在
py-src/data_formulator/error_handler.py。
| 你想做什么 | 用这个 | 一句话说明 |
|---|---|---|
| 返回成功 JSON | json_ok(data) | 统一成功信封 {"status":"success","data":...},HTTP 200 |
| 抛出业务/校验错误 | raise AppError(ErrorCode.XXX, "msg") | 全局 handler 自动捕获,HTTP 200(认证错误 401/403) |
| 流建立前校验失败 | stream_preflight_error(AppError(...)) | 返回 application/json error body,HTTP 200 |
| 流运行中 fatal error | yield stream_error_event(error) | 输出一行 NDJSON {"type":"error","error":{...}},流终止 |
| 流运行中非致命警告(generator 内) | yield stream_warning_event("msg") | 输出一行 NDJSON {"type":"warning","warning":{...}},流继续 |
| 流运行中非致命警告(helper/深层函数内) | collect_stream_warning("msg") | 攒到 flask.g,generator 用 flush_stream_warnings() 统一输出 |
| LLM / 外部 API 异常分类 | classify_and_wrap_llm_error(exc) | 把原始异常转成安全的 AppError,用于上面两个 stream helper |
安全规则:所有返回给客户端的 detail 字段(debug 模式才出现)都必须是安全摘要。
AppError.detail 会经过 sanitize_error_message() 清洗;未捕获异常永远不返回原始
traceback,只返回粗粒度错误分类和 request_id,完整 traceback 仅写入服务端日志。
DF 的非流式 JSON API 和流式预检错误以 body status 字段为主要判据。已建立的 NDJSON 流以事件 type 字段为判据。HTTP 状态码仅用于认证/授权或不可控传输层信号。
| 层级 | 规范 |
|---|---|
| HTTP 状态码 | 业务/校验错误 → 200;认证/授权错误 → 401/403;不可控错误 → 404/413/500 |
| 应用层 body | 非流式/流预检:成功 → "status": "success";失败 → "status": "error" |
| 结构化错误 | 必须使用 error: { code, message, retry } |
| 成功数据 | 必须包裹在 data 字段中:{"status": "success", "data": {...}} |
| 流内事件 | 已建立的 NDJSON 流使用 type 区分事件;fatal error → {"type": "error", "error": {...}} |
| HTTP 状态码 | 使用场景 | 由谁返回 |
|---|---|---|
200 | 成功响应与非认证/授权业务错误 | json_ok() / _handle_app_error() |
401 | AUTH_REQUIRED / AUTH_EXPIRED | _handle_app_error() |
403 | ACCESS_DENIED | _handle_app_error() |
404 | Flask 路由无匹配 | Flask 内置 |
413 | WSGI body 超限 | Flask 内置 |
500 | 未捕获异常(程序 bug) | _handle_unexpected() |
设计理由:
body.status === "error" 检测错误;流式调用通过 NDJSON type === "error" 检测流内错误| 普通 JSON API | NDJSON Streaming API | |
|---|---|---|
| 成功 HTTP 状态码 | 200 | 200 |
| 错误 HTTP 状态码 | 200(业务)/ 401/403(认证) | 流中 → 200(已建立);预检 → 200 |
| 成功 body | {"status": "success", "data": {...}} | NDJSON 业务事件流 |
| 错误 body | {"status": "error", "error": {...}} | NDJSON {"type": "error", "error": {...}} |
| 预检错误 | 同上 | 200 application/json + {"status": "error", ...} |
| 前端入口 | apiRequest() | streamRequest() |
使用 json_ok() 返回成功响应,raise AppError() 返回错误响应。
from data_formulator.errors import AppError, ErrorCode
from data_formulator.error_handler import json_ok
@bp.route("/my-endpoint", methods=["POST"])
def my_endpoint():
content = request.get_json()
if not content.get("table_name"):
raise AppError(ErrorCode.INVALID_REQUEST, "table_name is required")
try:
data = do_work(content)
except SomeKnownError as exc:
raise AppError(ErrorCode.DATA_LOAD_ERROR, "Failed to load data") from exc
return json_ok(data)
成功响应(HTTP 200):
{"status": "success", "data": {...}}
错误响应(HTTP 200,认证错误除外):
{
"status": "error",
"error": {
"code": "INVALID_REQUEST",
"message": "table_name is required",
"retry": false
}
}
ERROR_CODE_HTTP_STATUS 映射定义在 py-src/data_formulator/errors.py,AppError.get_http_status() 方法使用此映射。
仅认证/授权错误使用非 200:
| 错误码 | HTTP | 用途 |
|---|---|---|
AUTH_REQUIRED | 401 | 未登录 |
AUTH_EXPIRED | 401 | Token 过期 |
ACCESS_DENIED | 403 | 权限不足 |
所有其他 ErrorCode(业务错误、LLM 错误、数据错误等)统一返回 HTTP 200,
错误信息通过 body status: "error" + error: {...} 传递。
不在映射中的自定义 ErrorCode 也默认 HTTP 200。
json_ok() 用法from data_formulator.error_handler import json_ok
# 基本用法
return json_ok({"tables": ["a", "b"]})
# 自定义 HTTP 状态码(极少用)
return json_ok({"id": 1}, status_code=201)
Phase 2 迁移后,apiRequest() / parseApiResponse() 不再兼容旧响应格式。
以下格式只允许作为历史背景出现在旧提交或归档设计文档中:
return jsonify({"status": "ok", "data": data})
return jsonify({"status": "error", "message": "Table name is required"})
return jsonify({"status": "error", "error_message": "Model request failed"})
return jsonify({"error": "Something failed"})
如果现有 route 仍返回这些格式,必须先迁移到 json_ok() / AppError,再让前端通过
apiRequest() 消费。不要为了兼容历史 route 放宽 parseApiResponse()。
# BAD: 新代码不要用裸 jsonify 返回成功响应
return jsonify({"status": "ok", "data": data}) # → 用 json_ok(data)
# BAD: 不要直接暴露原始异常文本
return jsonify({"status": "error", "message": str(exc)})
# BAD: 新代码不要新增无 status 的临时错误格式
return jsonify({"error": "Something failed"})
# BAD: 不要在 json_ok 成功路径手动指定 HTTP 错误码
return json_ok(data), 400 # json_ok 已返回 (Response, status_code)
流式端点使用 NDJSON,详见 docs/dev-guides/1-streaming-protocol.md。
基本要求:
mimetype="application/x-ndjson"\n 结尾stream_error_event()stream_warning_event() 或 collect_stream_warning()str(exc)使用 stream_preflight_error() 返回错误。始终返回 HTTP 200,与非流式 API 行为一致。
from data_formulator.errors import AppError, ErrorCode
from data_formulator.error_handler import stream_preflight_error
if not request.is_json:
return stream_preflight_error(
AppError(ErrorCode.INVALID_REQUEST, "Invalid request format")
)
前端 streamRequest() 通过检测 content-type: application/json(而非
application/x-ndjson)识别预检失败并抛出 ApiRequestError。
from data_formulator.error_handler import (
classify_and_wrap_llm_error,
stream_error_event,
)
def generate():
try:
for event in agent.run(...):
yield json.dumps(event, ensure_ascii=False) + "\n"
except Exception as exc:
logger.exception("stream endpoint failed")
yield stream_error_event(classify_and_wrap_llm_error(exc))
警告用于"可继续但需要通知用户"的场景(如某张表不可用、降级到缓存等)。 前端以 toast / snackbar 展示,不中断流。
在 generator 内直接 yield:
from data_formulator.error_handler import stream_warning_event
def generate():
if fallback_used:
yield stream_warning_event(
"Table X unavailable, using cached version",
message_code="agent.tableFallback",
)
# ... 继续正常事件 ...
在不能 yield 的 helper / 深层函数内积累:
from data_formulator.error_handler import collect_stream_warning, flush_stream_warnings
# helper 函数——无法 yield,只能攒
def resolve_context(tables):
for t in tables:
if not available(t):
collect_stream_warning(f"Table {t} skipped", message_code="agent.tableSkipped")
# generator 侧定期 flush
def generate():
resolve_context(tables)
for line in flush_stream_warnings():
yield line
# ... 继续正常事件 ...
collect_stream_warning() 把警告存在 flask.g,flush_stream_warnings()
一次性取出并清空,返回已格式化的 NDJSON 行列表。
import { apiRequest, streamRequest } from '../app/apiClient';
import { handleApiError } from '../app/errorHandler';
// 非流式 API
try {
const { data } = await apiRequest<MyData>(url, options);
// use data
} catch (error) {
handleApiError(error, 'my-component');
}
// 流式 API
try {
for await (const event of streamRequest(url, options, abortController.signal)) {
if (event.type === 'error') {
// Inline/component-level handling if the stream context matters.
break;
}
}
} catch (error) {
handleApiError(error, 'my-component');
}
apiRequest() 的错误检测apiRequest() 使用双层检测:
!response.ok(非 2xx)→ 抛出 ApiRequestError(code: 'HTTP_ERROR')。仅在认证错误(401/403)或不可控传输错误时触发。body.status === 'error' → 抛出 ApiRequestError 并携带结构化错误信息。这是业务错误的主要检测路径。由于大部分应用错误返回 HTTP 200,前端实际通过 body status 字段判断成功/失败。
parseApiResponse() 兼容性parseApiResponse() 只接受当前统一格式:
status: "success" + data(Phase 2+)status: "error" + error: { code, message, retry, request_id? }旧的 status: "ok"、error_message、裸 message 会被视为 malformed response。
未迁移 route 必须在调用点自行兼容,不能要求 apiRequest() 放宽协议。
不是所有 .catch(() => {}) 都是 bug,但必须能解释:
| 场景 | 处理 |
|---|---|
| 用户主动操作失败 | 必须通知用户,例如 addMessages 或 handleApiError() |
| 后台 best-effort 加载 | 可以静默,但要加注释说明为何可忽略 |
| RTK thunk rejected | 必须有 .rejected handler,按 error.name 区分 AbortError(静默)、TimeoutError(超时提示)和业务错误(通用提示),参见 fetchChartInsight |
AbortError | 可直接忽略 |
createAsyncThunk 序列化边界重要:RTK createAsyncThunk 内部 throw 的错误会经过 miniSerializeError() 序列化
为普通 JS 对象 {name, message, stack},丢失 class 类型和自定义属性(如 apiError)。
调用 .unwrap() 时 .catch(error) 拿到的是这个普通对象,不是 Error 实例。
因此 String(error) 或模板字符串 `${error}` 会输出 [object Object]。
规范做法:
import { extractErrorMessage } from '../app/errorHandler';
// ✅ GOOD — 正确提取 message
dispatch(loadTable(...)).unwrap()
.catch((error) => {
const msg = extractErrorMessage(error);
// 用 msg 展示给用户
});
// ✅ GOOD — 使用 handleApiError() 统一处理
dispatch(loadTable(...)).unwrap()
.catch((error) => handleApiError(error, 'my-component'));
// ❌ BAD — 普通对象无法正确 String()
.catch((error) => `Failed: ${error}`) // → "Failed: [object Object]"
.catch((error) => String(error)) // → "[object Object]"
extractErrorMessage() 和 handleApiError() 都已处理 RTK 序列化对象,
会正确提取 .message 属性。
新增或重构前端 API 调用时,不要用 !data / data == null 推断 loading。
请求状态必须显式区分 idle、loading、success、empty、error,避免失败后
UI 因为没有 data 而继续显示 spinner。
对于组件内局部状态,优先使用 src/app/loadableState.ts:
import { handleApiError } from '../app/errorHandler';
import { LoadableState, errorLoadable, loadingLoadable, successLoadable } from '../app/loadableState';
const [catalogByConnector, setCatalogByConnector] =
useState<Record<string, LoadableState<CatalogCache>>>({});
setCatalogByConnector(prev => ({
...prev,
[connectorId]: loadingLoadable(prev[connectorId]),
}));
try {
const { data } = await apiRequest(...);
setCatalogByConnector(prev => ({
...prev,
[connectorId]: successLoadable(data, value => value.items.length === 0),
}));
} catch (error) {
setCatalogByConnector(prev => ({
...prev,
[connectorId]: errorLoadable(error, { items: [] }),
}));
handleApiError(error, 'my-component');
}
UI 渲染必须基于 state.status:
loading → spinner / disabled controlerror → 错误或空状态,不继续显示 spinnerempty → 空状态文案success → 正常数据以下路径不能简单套普通 JSON API 规范,评审时先确认具体协议:
| 场景 | 规范 |
|---|---|
| 文件下载 / CSV streaming | 成功响应可能是文件流或下载响应;错误响应仍应尽量使用安全的 status: "error" body |
| SPA fallback | 非 /api/ 路径没有匹配 Flask route 时继续返回前端入口 |
| OIDC redirect flow | 部分错误需要通过 redirect query param 传回前端展示 |
| 外部 URL fetch | 前端请求第三方 URL 时,!response.ok 属于第三方传输语义,不适用 DF API 约定 |
| 已建立的流式响应 | 流运行中出错只能通过 NDJSON type: "error" 事件传递,不能再修改 HTTP 状态码 |
| 流预检错误 | stream_preflight_error() 始终返回 HTTP 200 + application/json |
LLM / Agent 请求不要硬编码短 timeout。timeout 来源必须可解释:
| 请求类型 | timeout 来源 |
|---|---|
| 用户主动等待的 LLM / Agent 请求 | state.config.formulateTimeoutSeconds |
| 长链路 Agent + 多工具循环 | formulateTimeoutSeconds * N,必须在代码旁说明原因 |
| 后台 metadata / explanation 请求 | 不设置短前端 timeout;如果后端 API 已支持 timeout 参数,可选透传,否则使用后端默认 |
| 模型连通性检查 | 独立健康检查 timeout,可以短于推理 timeout |
| 数据库 / connector connect | 独立连接 timeout,并显示连接超时文案 |
| best-effort preview / debounce | 可短超时或无提示,但必须注释说明 |
系统 timeout 应与用户取消区分。推荐使用 AbortController.abort(reason) 传入
DOMException(..., "TimeoutError"),rejected reducer 通过 action.error.name
区分:
action.error.name | 含义 | 处理 |
|---|---|---|
TimeoutError | 系统超时 | 显示 warning,文案包含配置秒数或任务名称 |
AbortError | 用户取消或组件卸载 | 可静默 |
| 其他错误 | API / 业务错误 | 使用 getErrorMessage() 或本地 i18n 文案提示 |
fetchCodeExpl 和 fetchFieldSemanticType 不设置前端硬编码 timeout;新增同类后台
metadata 请求不要复制短客户端 abort 模式。
新增结构化错误码时同步修改:
py-src/data_formulator/errors.py: 添加 ErrorCode 常量(无需添加 HTTP 映射,默认 200)src/app/errorCodes.ts: 添加 ERROR_CODE_I18N_MAPsrc/i18n/locales/en/errors.json: 添加英文文案src/i18n/locales/zh/errors.json: 添加中文文案前端通过 getErrorMessage(apiError) 优先使用本地 i18n,缺失时回退到后端英文 message。
普通后端固定消息如果不是 AppError 体系,优先参考 docs/dev-guides/6-i18n-language-injection.md 的 message_code / content_code 规则。
| 文件 | 工具 | 用途 |
|---|---|---|
py-src/data_formulator/errors.py | ErrorCode, AppError, ERROR_CODE_HTTP_STATUS | 结构化应用错误(仅 auth 映射非 200) |
py-src/data_formulator/error_handler.py | register_error_handlers() | 全局错误处理和 X-Request-Id |
py-src/data_formulator/error_handler.py | json_ok() | 统一成功响应 helper |
py-src/data_formulator/error_handler.py | stream_preflight_error() | 流预检错误 helper(HTTP 200) |
py-src/data_formulator/error_handler.py | classify_and_wrap_llm_error() | LLM/外部 API 异常安全分类 |
py-src/data_formulator/error_handler.py | stream_error_event() | NDJSON error 事件(fatal,流终止) |
py-src/data_formulator/error_handler.py | stream_warning_event() | NDJSON warning 事件(非致命,流继续) |
py-src/data_formulator/error_handler.py | collect_stream_warning() / flush_stream_warnings() | 跨函数积累 warning,generator 统一 flush |
py-src/data_formulator/routes/tables.py | classify_and_raise_db_error() | 表/工作区错误分类 |
py-src/data_formulator/data_loader/connector_errors.py | classify_connector_error() | DataLoader/connector 简单错误分类 |
py-src/data_formulator/data_connector.py | classify_and_raise_connector_error() | 连接器路由兼容入口 |
sanitize_db_error_message()、_sanitize_error()、safe_error_response() 等 legacy wrapper 仅为兼容保留,新代码不要调用。
所有 AppError、404、413、未捕获 500 的 JSON 错误体都必须包含
error.request_id,同时响应头带 X-Request-Id。前端可把这个 ID 展示给用户,
用于定位后端日志。生产环境不要把未捕获异常的原始文本、traceback 或连接串返回给前端。
即使在 debug 模式下,AppError.detail 和 traceback.format_exc() 返回到客户端
之前也会经过 sanitize_error_message() 清洗——剥离文件路径、凭据和完整堆栈帧,
只保留可操作的错误摘要(如 ValueError: invalid literal)。完整日志始终通过
logger.exception() 写入服务端日志。
DataLoader/connector 只做简单实用分类,不为每个 SDK 维护专门错误树:
| 类别 | ErrorCode |
|---|---|
| 参数/请求问题 | INVALID_REQUEST |
| 数据源鉴权失败 | CONNECTOR_AUTH_FAILED |
| 登录过期 | AUTH_EXPIRED |
| 权限不足 | ACCESS_DENIED |
| 连接失败/超时 | DB_CONNECTION_FAILED |
| 查询语法或执行失败 | DB_QUERY_ERROR |
| 文件/资源/解析/导入失败 | DATA_LOAD_ERROR |
| 其他连接器异常 | CONNECTOR_ERROR |
顶层 connector 操作失败应抛 AppError;批量导入或自动连接这类局部失败可以保留
status: "success",但局部项必须带结构化 error: { code, message, retry }。
后端测试:
AppError 路径断言 HTTP 200 + body status == "error" + error.code 匹配404(无路由)、413(body 超限)、未捕获 500 保持相应非 200 状态码error.request_id 与响应头 X-Request-Id 对齐type: "error"前端测试:
parseApiResponse() 覆盖 status: "success"、结构化错误、request_idparseApiResponse() 断言 legacy status: "ok" / error_message 被拒绝apiRequest() 覆盖 HTTP 401/403、HTTP 200 body 错误、非 JSON 响应streamRequest() 覆盖 200 application/json 预检错误和 NDJSON error 事件handleApiError() 覆盖 AbortError、auth 回调、retry 回调、silent 模式errorCodes.ts 覆盖已知 code 翻译和未知 code fallback当前已有后端协议合约测试、前端 apiClient 测试和轻量 ESLint 护栏。其他尚未
落地的自动化护栏不要在设计文档或评审中标记为已完成:
scripts/check_api_error_guardrails.py 静态扫描脚本暂不实现;只有在误用反复出现时再考虑。fetchWithIdentity().json() / (await fetchWithIdentity()).json();
更复杂的 fetchWithIdentity 用法仍按人工评审和统一 API 规范判断。json_ok(data) → {"status": "success", "data": ...}raise AppError(ErrorCode.XXX, "message") → HTTP 200 + error bodystr(exc)apiRequest() 消费mimetype='application/x-ndjson'stream_with_context(_with_warnings(generate())) 包裹stream_preflight_error(AppError(...))stream_error_event(classify_and_wrap_llm_error(e))type: "error" 和 type: "warning"str(e) / str(exc)