agent学习Day9——异常兜底与重试护栏

发布时间:2026/7/18 1:32:40
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——定义异常基类 统一响应模型classAppException(Exception):def__init__(self,code:int,message:str,detail:objectNone):self.codecode self.messagemessage self.detaildetailsuper().__init__(message)classServiceError(AppException):def__init__(self,message上游 LLM 超时/不可用,detailNone):super().__init__(code503,messagemessage,detaildetail)classErrorResponse(BaseModel):code:intmessage:strdetail:object|NoneNone# object 啥都能塞字符串、列表、字典AppException是基类所有业务异常继承它一个处理器收全部子类。detail: object | None是关键——422 时塞校验错误列表数组503 时只一句话字符串一个字段同时容得下。app/api/error_handlers.py——四个处理器asyncdefapp_exception_handler(request,exc:AppException):returnJSONResponse(status_codeexc.code,contentErrorResponse(codeexc.code,messageexc.message,detailexc.detail).model_dump())asyncdefvalidation_exception_handler(request,exc:RequestValidationError):returnJSONResponse(status_code422,contentErrorResponse(code422,message请求参数校验失败,detailexc.errors()).model_dump())asyncdefunhandled_exception_handler(request,exc:Exception):returnJSONResponse(status_code500,contentErrorResponse(code500,message服务器内部错误,detailNone).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进重试分支AuthenticationErrorKey 无效直接抛绝不重试。指数退避等待 base * 2^attempt加min(..., max_delay)封顶。每次失败后等更久给对方越来越大的恢复窗口。随机抖动等待 random.uniform(0, 退避)把一批同步失败的请求重试时刻打散成一片消除周期性惊群。改造在LlmService.call_llm内forattemptinrange(max_retries):try:responseawaitself.client.chat.completions.create(...)contentresponse.choices[0].message.contentifcontentisNone:raiseValueError(LLM returned empty content)returncontentexcept(openai.RateLimitError,openai.APITimeoutError,openai.APIConnectionError,openai.InternalServerError)ase:ifattemptmax_retries-1:raiseServiceError(message上游 LLM 多次重试仍失败,detailstr(e))await_wait_with_backoff(attempt)continueexceptopenai.AuthenticationErrorase:raiseServiceError(messageLLM 认证失败请检查 API Key,detailstr(e))重试耗尽抛ServiceError(503)——正好被第二章的全局处理器接住统一返回{code:503,...}。3.2 的出口对接 3.1 的入口用一个异常类串起来。五、重试的边界该不该 vs 能不能最容易被绕晕的一点重试for循环只包住create调用。如果 LLM 成功返回了但内容是一段废话坏 JSONValueError是在call_llm已经return content之后、analyze_jd的parse_jd_analysis里才抛的——根本不在重试循环的射程内所以当前不会重试。这不是该不该重试的语义问题是能不能重试的结构问题。要让坏 JSON 也重试得两步①把解析移进call_llm的 try 内return 前②except加ValueError进可重试分支。分清这两层才算真懂重试边界。六、怎么验dependency_overrides 偷梁换柱异常和重试都得测但不能真调 LLM费钱慢。前面的博客讲过app.dependency_overrides这把钥匙测试时把真依赖换成假依赖——今天用它注入故意抛异常的假 serviceclassFakeTimeoutService:asyncdefanalyze_jd(self,jd_text):raiseServiceError(message上游 LLM 超时/不可用)app.dependency_overrides[get_llm_service]lambda:FakeTimeoutService()rclient.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_count3。坑TestClient默认raise_server_exceptionsTrue未接住的异常会在测试端重抛、盖住 500 兜底。验 500 必须TestClient(app, raise_server_exceptionsFalse)。七、小结全局异常兜底和指数退避重试是接口稳定性的两道护栏前者保证出了任何错都返回统一格式、不泄露堆栈后者保证瞬时故障能自愈、又不放大故障。两者通过ServiceError(503)一个异常类对接——重试耗尽抛它全局处理器接它。把 LLM 调用从裸奔等死升级成兜底 自愈是后端服务能扛真实流量的关键一步。