Files
q792602257andClaude Opus 5 8381896eeb feat(observability): 失败响应与网关信封进链路,手工埋点尊重 suppressed
失败此前在 trace 里近乎不可见:异常处理器把异常吃掉换成信封响应,自动
instrumentation 只看到一个 HTTP 状态码,而 AppError 默认 400、信封里
success=false,跟正常返回分不出来。

- api.py:四个异常处理器(对外失败的唯一出口)各记一次 span;兜底处理器额外
  record_error——对外只回一句无信息量的错误文案,异常类型与栈只在本地日志里
- telemetry.py:新增 record_envelope / record_parse_failure /
  span_unless_suppressed;record_error 补 error.message / retryable /
  status_code;snapshot 支持 extra 带上「这份 HTML 是哪来的」
- worker/client.py:_request 自建 span,活到解信封之后。httpx 那个 CLIENT span
  在 request() 返回时就结束,此时信封还没解——success=false code=6002(租约
  无效)在它看来是完成的 200 请求
- span_unless_suppressed:suppress_instrumentation 只被 instrumentation 库尊重,
  手工 span 不看它,lease 空转长轮询会从这个口子把孤立 trace 放回来
- 两个 scraping client 的解析失败分支收拢到 record_parse_failure;shop_items
  显式标 stage=delegate 且不落快照(它自己不抓页面,按 html 推断只会得出
  「fetch 失败」的错误结论)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 16:07:00 +08:00

220 lines
8.9 KiB
Python

"""两个入口共用的 HTTP 层:响应信封、鉴权依赖、异常处理器
抓取服务与交易服务是两个独立进程(见 README「两个部署单元」),但对外契约必须
一致:同一套 `ApiResponse` 信封、同一份错误码表、同一个 Bearer token。共用的部分
集中在这里,两侧的差异只体现在各自注册的路由与容器。
这里刻意不放任何站点或业务知识——`get_container` 不标注具体容器类型,shared 因此
不需要认识 `ScrapingContainer` / `TradingContainer`,避免共用层反向依赖两侧。
"""
from __future__ import annotations
import logging
import secrets
from typing import Any, Generic, TypeVar
from fastapi import Depends, FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from opentelemetry import trace
from pydantic import BaseModel, Field, ValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.shared.errors import AppError, AuthenticationError
from app.shared.telemetry import record_envelope, record_error
logger = logging.getLogger(__name__)
T = TypeVar("T")
class ApiResponse(BaseModel, Generic[T]):
"""统一 API 响应格式"""
success: bool = Field(description="请求是否成功")
msg: str = Field(description="提示信息;失败时为错误原因")
data: T | None = Field(default=None, description="响应数据;无数据或失败时为 null")
code: int = Field(
description="错误码:0 表示成功,其余见统一错误码表(如 1001 鉴权失败、"
"1002 请求参数校验失败、5xxx 交易服务、6xxx 网关)"
)
# ---- 依赖注入 ----
def get_container(request: Request) -> Any:
"""从请求中获取服务容器
返回类型故意留成 Any:抓取侧与交易侧的容器结构不同,由各自路由标注具体类型。
"""
return request.app.state.container
def require_bearer_token(
request: Request,
container: Any = Depends(get_container),
) -> None:
"""Bearer Token 鉴权依赖
从请求头 Authorization 中提取 Bearer Token,
与服务端配置的 token 做安全比较(使用 secrets.compare_digest 防止时序攻击)。
"""
authorization = request.headers.get("Authorization")
if not authorization:
logger.warning("鉴权失败:缺少 Authorization 请求头")
raise AuthenticationError("Missing Authorization header")
token = authorization.replace("Bearer ", "", 1).strip()
if token == authorization:
logger.warning("鉴权失败:Authorization scheme 非 Bearer")
raise AuthenticationError("Invalid Authorization scheme")
if not token:
logger.warning("鉴权失败:Bearer token 为空")
raise AuthenticationError("Invalid token")
if not secrets.compare_digest(token, container.settings.bearer_token):
logger.warning("鉴权失败:token 不匹配")
raise AuthenticationError("Invalid token")
# ---- 异常处理 ----
def _format_validation_msg(errors: list[dict]) -> str:
"""将校验错误整理为便于前端展示的消息。"""
if not errors:
return "Validation error"
messages: list[str] = []
for error in errors:
loc = ".".join(str(part) for part in error.get("loc", []) if part != "body")
msg = str(error.get("msg", "Validation error"))
messages.append(f"{loc}: {msg}" if loc else msg)
return "; ".join(messages)
def jsonable_errors(errors: list[dict]) -> list[dict]:
"""剔除校验错误里不可 JSON 序列化的 ctx(如原始异常对象)"""
return [{key: value for key, value in error.items() if key != "ctx"} for error in errors]
def _trace_failure(
exc: BaseException | None, *, err_code: int, msg: str, status_code: int
) -> None:
"""把一次失败响应记到当前 server span 上(FastAPI 自动 instrumentation 建的那个)
这些处理器是所有对外失败的**唯一出口**,也是链路上唯一还知道「异常长什么样」
的地方:它们把异常吃掉换成 200/4xx 的信封响应,异常不再向上冒,自动
instrumentation 只看得到一个 HTTP 状态码。尤其 AppError 默认 status_code=400、
信封里 `success=false`,在 trace 里跟正常返回几乎分不出来——不在这里记一次,
上游报「调用失败了」时链路里根本找不到对应的错误。
span 没在录(otel 关闭、或 /health 这类被 excluded_urls 排除的路径)时
`set_attributes` / `record_error` 都作用在 NonRecordingSpan 上,是 noop,
不必额外判断。
"""
span = trace.get_current_span()
if exc is not None:
record_error(span, exc)
record_envelope(span, success=False, err_code=err_code, msg=msg, status_code=status_code)
def register_exception_handlers(app: FastAPI) -> None:
"""给应用挂上全套异常处理器
两个入口都调用它,保证抓取失败与下单失败返回的错误结构完全一致,
上游只需要按 code 分支,不必区分是哪个服务回的。
每个处理器除了构造响应,还把这次失败记到当前 server span 上(见
`_trace_failure`)——处理器是失败的唯一出口,不记就等于链路里没有这次失败。
"""
@app.exception_handler(AppError)
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
"""业务异常处理器:返回结构化的错误响应"""
_trace_failure(
exc, err_code=exc.err_code, msg=exc.message, status_code=exc.status_code
)
return JSONResponse(
status_code=exc.status_code,
content=ApiResponse[None](
success=False,
code=exc.err_code,
msg=exc.message,
data=None,
).model_dump(),
headers=exc.headers,
)
@app.exception_handler(RequestValidationError)
async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
"""请求参数校验异常处理器"""
errors = exc.errors()
msg = _format_validation_msg(errors)
# 校验失败不记异常本身(pydantic 的 ValidationError 栈很长且没有诊断价值),
# 只留错误码与整理后的字段消息——排查要看的是「哪个字段不合法」
_trace_failure(None, err_code=1002, msg=msg, status_code=422)
return JSONResponse(
status_code=422,
content=ApiResponse[object](
success=False,
msg=msg,
data=jsonable_errors(errors),
code=1002,
).model_dump(),
)
@app.exception_handler(ValidationError)
async def pydantic_validation_error_handler(_: Request, exc: ValidationError) -> JSONResponse:
"""Pydantic 模型校验异常处理器"""
errors = exc.errors()
msg = _format_validation_msg(errors)
_trace_failure(None, err_code=1002, msg=msg, status_code=422)
return JSONResponse(
status_code=422,
content=ApiResponse[object](
success=False,
msg=msg,
data=jsonable_errors(errors),
code=1002,
).model_dump(),
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(_: Request, exc: StarletteHTTPException) -> JSONResponse:
"""HTTP 异常处理器(404、500 等)"""
status_code = int(getattr(exc, "status_code", 500) or 500)
err_code = 1404 if status_code == 404 else 1500
detail = str(getattr(exc, "detail", "HTTP error"))
_trace_failure(None, err_code=err_code, msg=detail, status_code=status_code)
return JSONResponse(
status_code=status_code,
content=ApiResponse[None](
success=False,
msg=detail,
data=None,
code=err_code,
).model_dump(),
headers=getattr(exc, "headers", None),
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(_: Request, exc: Exception) -> JSONResponse:
"""兜底异常处理器:捕获所有未处理的异常"""
logger.exception("未处理异常:%s", exc)
# 这里最需要 record_error:对外只回一句无信息量的 "Internal server error",
# 真正的异常类型与栈只在本进程日志里。记到 span 上,链路里就能直接看到
# 是什么炸了,不必再去捞日志按时间对。
_trace_failure(exc, err_code=1500, msg="Internal server error", status_code=500)
return JSONResponse(
status_code=500,
content=ApiResponse[None](
success=False,
msg="Internal server error",
data=None,
code=1500,
).model_dump(),
)