agent学习Day9——异常兜底与重试护栏
2026/7/18 1:32:38 网站建设 项目流程

让接口不再裸奔:全局异常兜底与指数退避重试

一、裸奔的代价:为什么路由不能没有异常兜底

之前/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——定义异常基类 + 统一响应模型:

classAppException(Exception):def__init__(self,code:int,message:str,detail:object=None):self.code=code self.message=message self.detail=detailsuper().__init__(message)classServiceError(AppException):def__init__(self,message="上游 LLM 超时/不可用",detail=None):super().__init__(code=503,message=message,detail=detail)classErrorResponse(BaseModel):code:intmessage:strdetail:object|None=None# object 啥都能塞:字符串、列表、字典

AppException是基类,所有业务异常继承它,一个处理器收全部子类。detail: object | None是关键——422 时塞校验错误列表(数组),503 时只一句话(字符串),一个字段同时容得下。

app/api/error_handlers.py——四个处理器:

asyncdefapp_exception_handler(request,exc:AppException):returnJSONResponse(status_code=exc.code,content=ErrorResponse(code=exc.code,message=exc.message,detail=exc.detail).model_dump())asyncdefvalidation_exception_handler(request,exc:RequestValidationError):returnJSONResponse(status_code=422,content=ErrorResponse(code=422,message="请求参数校验失败",detail=exc.errors()).model_dump())asyncdefunhandled_exception_handler(request,exc:Exception):returnJSONResponse(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是它的子类。异常匹配规则是"被抛的异常是不是注册类型的实例"——父类实例不是子类实例,匹配失败,退回默认。

修复:注册父类。

fromstarlette.exceptionsimportHTTPExceptionasStarletteHTTPException 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内:

forattemptinrange(max_retries):try:response=awaitself.client.chat.completions.create(...)content=response.choices[0].message.contentifcontentisNone:raiseValueError("LLM returned empty content")returncontentexcept(openai.RateLimitError,openai.APITimeoutError,openai.APIConnectionError,openai.InternalServerError)ase:ifattempt==max_retries-1:raiseServiceError(message="上游 LLM 多次重试仍失败",detail=str(e))await_wait_with_backoff(attempt)continueexceptopenai.AuthenticationErrorase:raiseServiceError(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_jdparse_jd_analysis里才抛的——根本不在重试循环的射程内,所以当前不会重试。

这不是"该不该重试"的语义问题,是"能不能重试"的结构问题。要让坏 JSON 也重试,得两步:①把解析移进call_llm的 try 内(return 前);②exceptValueError进可重试分支。分清这两层,才算真懂重试边界。

六、怎么验:dependency_overrides 偷梁换柱

异常和重试都得测,但不能真调 LLM(费钱慢)。前面的博客讲过app.dependency_overrides这把钥匙(测试时把真依赖换成假依赖)——今天用它注入"故意抛异常"的假 service:

classFakeTimeoutService:asyncdefanalyze_jd(self,jd_text):raiseServiceError(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 调用从"裸奔等死"升级成"兜底 + 自愈",是后端服务能扛真实流量的关键一步。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询