|
| 1 | +"""异常 → HTTP 响应映射(TECHNICAL §6.5:稳定的机器可读错误码)。 |
| 2 | +
|
| 3 | +管理端点返回的失败原因必须是稳定枚举,不能是 Python 异常类名—— |
| 4 | +类名是实现细节,用户既判断不出问题也不知道下一步做什么,重构时还会漂移。 |
| 5 | +""" |
| 6 | + |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +import logging |
| 10 | + |
| 11 | +import httpx |
| 12 | +from fastapi import FastAPI |
| 13 | +from fastapi.responses import JSONResponse |
| 14 | + |
| 15 | +from ..auth.csrf import CsrfRejectedError |
| 16 | +from ..auth.rbac import ForbiddenError, UnauthorizedError |
| 17 | +from ..auth.throttle import ThrottledError |
| 18 | +from ..compat.openai.errors import error_payload |
| 19 | +from ..compat.openai.request import InvalidRequest |
| 20 | +from ..db.crypto import CredentialDecryptError |
| 21 | +from ..engine.executor import NoHealthyCredential, NoProviderForModel |
| 22 | +from ..engine.model_resolver import UnknownModelError |
| 23 | +from ..provider.codebuddy.events import ( |
| 24 | + UpstreamProtocolViolation as CodeBuddyProtocolViolation, |
| 25 | +) |
| 26 | +from ..provider.trae.events import UpstreamProtocolViolation |
| 27 | + |
| 28 | +logger = logging.getLogger(__name__) |
| 29 | + |
| 30 | +# 上游连接/超时失败:502(调用方可重试),与凭证健康度无关。 |
| 31 | +UPSTREAM_ERROR_STATUS = 502 |
| 32 | + |
| 33 | + |
| 34 | +def register_exception_handlers(app: FastAPI) -> None: |
| 35 | + """把全部异常处理器注册到 app(顺序无关,FastAPI 按类型匹配)。""" |
| 36 | + |
| 37 | + @app.exception_handler(UnauthorizedError) |
| 38 | + async def _unauthorized(_request, _error: UnauthorizedError): |
| 39 | + return JSONResponse(status_code=401, |
| 40 | + content=error_payload("invalid authentication credentials", |
| 41 | + "invalid_api_key", 401)) |
| 42 | + |
| 43 | + @app.exception_handler(InvalidRequest) |
| 44 | + async def _invalid(_request, error: InvalidRequest): |
| 45 | + return JSONResponse(status_code=400, |
| 46 | + content=error_payload(str(error), "invalid_request", 400)) |
| 47 | + |
| 48 | + @app.exception_handler(UnknownModelError) |
| 49 | + async def _unknown_model(_request, error: UnknownModelError): |
| 50 | + return JSONResponse(status_code=400, |
| 51 | + content=error_payload(str(error), "invalid_request", 400)) |
| 52 | + |
| 53 | + @app.exception_handler(UpstreamProtocolViolation) |
| 54 | + async def _bad_credential(_request, error: UpstreamProtocolViolation): |
| 55 | + return JSONResponse(status_code=400, |
| 56 | + content=error_payload(str(error), "invalid_credential", 400)) |
| 57 | + |
| 58 | + @app.exception_handler(CodeBuddyProtocolViolation) |
| 59 | + async def _bad_codebuddy_credential(_request, error: CodeBuddyProtocolViolation): |
| 60 | + return JSONResponse(status_code=400, |
| 61 | + content=error_payload(str(error), "invalid_credential", 400)) |
| 62 | + |
| 63 | + @app.exception_handler(NoHealthyCredential) |
| 64 | + async def _no_health(_request, error: NoHealthyCredential): |
| 65 | + return JSONResponse(status_code=503, |
| 66 | + content=error_payload(str(error), "no_healthy_credential", 503)) |
| 67 | + |
| 68 | + @app.exception_handler(NoProviderForModel) |
| 69 | + async def _no_provider(_request, error: NoProviderForModel): |
| 70 | + return JSONResponse(status_code=400, |
| 71 | + content=error_payload(str(error), "invalid_request", 400)) |
| 72 | + |
| 73 | + @app.exception_handler(ForbiddenError) |
| 74 | + async def _forbidden(_request, _error: ForbiddenError): |
| 75 | + return JSONResponse(status_code=403, |
| 76 | + content=error_payload("admin only", "forbidden", 403)) |
| 77 | + |
| 78 | + @app.exception_handler(CsrfRejectedError) |
| 79 | + async def _csrf_rejected(_request, _error: CsrfRejectedError): |
| 80 | + return JSONResponse(status_code=403, |
| 81 | + content=error_payload("cross-origin write rejected", |
| 82 | + "forbidden", 403)) |
| 83 | + |
| 84 | + @app.exception_handler(CredentialDecryptError) |
| 85 | + async def _decrypt_failed(_request, _error: CredentialDecryptError): |
| 86 | + """APP_SECRET 变更或密文损坏:必须给出可行动提示,而不是 500。""" |
| 87 | + logger.error("凭证解密失败:APP_SECRET 是否被更换过?") |
| 88 | + return JSONResponse(status_code=500, |
| 89 | + content=error_payload( |
| 90 | + "credential decryption failed; APP_SECRET may have changed", |
| 91 | + "credential_decrypt_failed", 500)) |
| 92 | + |
| 93 | + @app.exception_handler(httpx.TransportError) |
| 94 | + async def _transport_error(_request, error: httpx.TransportError): |
| 95 | + """上游连接/超时失败:502 而不是 500(调用方可重试)。 |
| 96 | +
|
| 97 | + httpx 的 TimeoutException/ConnectError 在引擎里不被 _classify 认识 |
| 98 | + (没有 kind()),会直接冒泡——以前表现成 500,语义错误。 |
| 99 | + """ |
| 100 | + logger.warning("上游传输层失败: %s: %s", type(error).__name__, error) |
| 101 | + return JSONResponse( |
| 102 | + status_code=UPSTREAM_ERROR_STATUS, |
| 103 | + content=error_payload(f"upstream transport failed: {type(error).__name__}", |
| 104 | + "upstream_unavailable", UPSTREAM_ERROR_STATUS)) |
| 105 | + |
| 106 | + @app.exception_handler(ThrottledError) |
| 107 | + async def _throttled(_request, _error: ThrottledError): |
| 108 | + response = JSONResponse(status_code=429, |
| 109 | + content=error_payload( |
| 110 | + "too many login attempts, slow down", |
| 111 | + "rate_limited", 429)) |
| 112 | + # OpenAI 客户端按 Retry-After 退避;缺失会立即重试加剧限流 |
| 113 | + response.headers.setdefault("Retry-After", "60") |
| 114 | + return response |
0 commit comments