agent学习Day9——异常兜底与重试护栏
让接口不再裸奔:全局异常兜底与指数退避重试
一、裸奔的代价:为什么路由不能没有异常兜底
之前 /analyze-basic-llm 路由里只有一行 return await llm_service.analyze_jd(req.jd_text)——纯裸奔,零 try/except。service 层 catch 了异常也只是记日志再 raise,最后全砸到 FastAPI 默认兜底上。
裸奔的代价有四个:
- 状态码塌缩:LLM 超时、返回坏 JSON、代码 bug,全变成 500。客户端分不清"我请求错了"“上游挂了"还是"服务自己炸了”,监控告警也瞎。
- 格式不统一:500 是
{"detail":"..."}(字符串),422 校验失败是{"detail":[...]}(数组),前端没法一套逻辑处理。 - 堆栈泄露:开发模式 500 会吐 Python 堆栈,生产环境是安全隐患。
- 手写 try/except 不可持续:异常类型一只手数不过来,N 个接口 × M 种异常 = N×M 个 try/except,必漏必错。
解决思路一句话:一处定义异常映射,全局生效。路由继续专心写业务,所有异常交给"分诊台"统一翻译。
二、三个文件搭起"分诊台"
FastAPI 的 @app.exception_handler(异常类) 是全局分诊台:内部维护「异常类型 → 处理器」映射表,异常冒泡到最外层按继承链最具体匹配。一处注册,全局生效。
落到三个文件:
app/core/exceptions.py——定义异常基类 + 统一响应模型:
class AppException(Exception):
def __init__(self, code: int, message: str, detail: object = None):
self.code = code
self.message = message
self.detail = detail
super().__init__(message)
class ServiceError(AppException):
def __init__(self, message="上游 LLM 超时/不可用", detail=None):
super().__init__(code=503, message=message, detail=detail)
class ErrorResponse(BaseModel):
code: int
message: str
detail: object | None = None # object 啥都能塞:字符串、列表、字典
AppException 是基类,所有业务异常继承它,一个处理器收全部子类。detail: object | None 是关键——422 时塞校验错误列表(数组),503 时只一句话(字符串),一个字段同时容得下。
app/api/error_handlers.py——四个处理器:
async def app_exception_handler(request, exc: AppException):
return JSONResponse(status_code=exc.code,
content=ErrorResponse(code=exc.code, message=exc.message, detail=exc.detail).model_dump())
async def validation_exception_handler(request, exc: RequestValidationError):
return JSONResponse(status_code=422,
content=ErrorResponse(code=422, message="请求参数校验失败", detail=exc.errors()).model_dump())
async def unhandled_exception_handler(request, exc: Exception):
return JSONResponse(status_code=500,
content=ErrorResponse(code=500, message="服务器内部错误", detail=None).model_dump())
AppException 处理器收所有业务异常(含 ServiceError(503));RequestValidationError 接住 422 校验;Exception 兜底处理器故意不给 detail 堆栈,防泄露。
app/main.py——注册(关键一步,不注册不生效):
app.add_exception_handler(AppException, app_exception_handler)
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
app.add_exception_handler(Exception, unhandled_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
路由层一行没动,异常冒泡上来自动翻译。
三、父类才能接住父类异常
注册 HTTPException 处理器后,打一个不存在的路径,返回却是 Starlette 默认的 {"detail":"Not Found"}——我们的处理器没接住。
根因:Starlette 找不到路由时抛的是 starlette.exceptions.HTTPException(父类),而 FastAPI 的 HTTPException 是它的子类。异常匹配规则是"被抛的异常是不是注册类型的实例"——父类实例不是子类实例,匹配失败,退回默认。
修复:注册父类。
from starlette.exceptions import HTTPException as StarletteHTTPException
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
要接住"所有 HTTP 异常",得注册父类,不能注册子类。 这个坑把"继承链匹配方向"钉死了。
四、瞬时故障自愈:指数退避重试
全局异常兜底解决"出了错怎么兜底",但有些错能自愈——LLM 调用遇 429 限流、超时、5xx,过几秒可能就好了。无脑重试会放大故障(惊群),所以要有策略。
重试三要素,缺一不可:
- 分类重试:只重试"瞬时故障"。
RateLimitError/APITimeoutError/APIConnectionError/InternalServerError进重试分支;AuthenticationError(Key 无效)直接抛,绝不重试。 - 指数退避:
等待 = base * 2^attempt,加min(..., max_delay)封顶。每次失败后等更久,给对方越来越大的恢复窗口。 - 随机抖动:
等待 += random.uniform(0, 退避),把一批同步失败的请求重试时刻打散成一片,消除周期性惊群。
改造在 LlmService.call_llm 内:
for attempt in range(max_retries):
try:
response = await self.client.chat.completions.create(...)
content = response.choices[0].message.content
if content is None:
raise ValueError("LLM returned empty content")
return content
except (openai.RateLimitError, openai.APITimeoutError,
openai.APIConnectionError, openai.InternalServerError) as e:
if attempt == max_retries - 1:
raise ServiceError(message="上游 LLM 多次重试仍失败", detail=str(e))
await _wait_with_backoff(attempt)
continue
except openai.AuthenticationError as e:
raise ServiceError(message="LLM 认证失败,请检查 API Key", detail=str(e))
重试耗尽抛 ServiceError(503)——正好被第二章的全局处理器接住,统一返回 {code:503,...}。3.2 的出口对接 3.1 的入口,用一个异常类串起来。
五、重试的边界:该不该 vs 能不能
最容易被绕晕的一点:重试 for 循环只包住 create 调用。如果 LLM 成功返回了,但内容是一段废话(坏 JSON),ValueError 是在 call_llm 已经 return content 之后、analyze_jd 的 parse_jd_analysis 里才抛的——根本不在重试循环的射程内,所以当前不会重试。
这不是"该不该重试"的语义问题,是"能不能重试"的结构问题。要让坏 JSON 也重试,得两步:①把解析移进 call_llm 的 try 内(return 前);②except 加 ValueError 进可重试分支。分清这两层,才算真懂重试边界。
六、怎么验:dependency_overrides 偷梁换柱
异常和重试都得测,但不能真调 LLM(费钱慢)。前面的博客讲过 app.dependency_overrides 这把钥匙(测试时把真依赖换成假依赖)——今天用它注入"故意抛异常"的假 service:
class FakeTimeoutService:
async def analyze_jd(self, jd_text):
raise ServiceError(message="上游 LLM 超时/不可用")
app.dependency_overrides[get_llm_service] = lambda: FakeTimeoutService()
r = client.post("/api/v1/jd/analyze-basic-llm", json={"jd_text": "招 Python 工程师"})
# → 503 {"code":503,"message":"上游 LLM 超时/不可用","detail":null}
生产路由一行不动,测试把真 LlmService 换成假货,白嫖验证 503/500。重试则用 unittest.mock 替换底层 client.chat.completions.create,造"前两次超时、第三次成功",验证 call_count==3。
坑:TestClient 默认 raise_server_exceptions=True,未接住的异常会在测试端重抛、盖住 500 兜底。验 500 必须 TestClient(app, raise_server_exceptions=False)。
七、小结
全局异常兜底和指数退避重试是接口稳定性的两道护栏:前者保证"出了任何错都返回统一格式、不泄露堆栈",后者保证"瞬时故障能自愈、又不放大故障"。两者通过 ServiceError(503) 一个异常类对接——重试耗尽抛它,全局处理器接它。把 LLM 调用从"裸奔等死"升级成"兜底 + 自愈",是后端服务能扛真实流量的关键一步。
更多推荐



所有评论(0)