接口报 500 了,日志里却空空的?FastAPI 异常处理最佳实践

0 阅读7分钟

在这里插入图片描述

聊聊 AI 后端那些事儿 · 第 2 篇 | 阅读大约需 10 分钟


那个神秘的 500 错误

周三下午,前端同事发来消息:"小禾,生成图片的接口挂了,返回 500。"

小禾打开服务器日志:

2024-01-15 14:30:00 | INFO | 应用启动完成
2024-01-15 14:30:05 | INFO | 收到请求: POST /api/generate
...

然后就没了。

没有错误信息,没有堆栈跟踪,什么都没有。

小禾本地跑了一遍,正常。

他又看了看前端控制台:

POST /api/generate 500 (Internal Server Error)
{"detail": "Internal Server Error"}

就这么一行,没有任何有用的信息。

小禾开始漫长的排查之旅:

14:35 - 加了几个 print,重新部署
14:50 - 发现 print 没输出,可能没走到那
15:20 - 又加了几个 print,又部署
15:45 - 终于发现是某个深层函数抛了异常
16:00 - 定位到问题:一个变量是 None
16:10 - 修复,部署,测试通过

三个小时,就为了找一个空指针。

小禾越想越气:为什么异常没打日志?

他翻了翻代码,找到了罪魁祸首:

try:
    result = some_function()
except Exception:
    pass  # 就是这行!

某位前人写的代码,捕获了所有异常,然后什么都不做。

异常就这么被吞掉了,悄无声息。


异常处理的三个层级

痛定思痛,小禾决定重构整个异常处理体系。

他画了张架构图:

flowchart TB
    A[HTTP 请求] --> B[Layer 1: 全局异常处理器]
    B --> C[Layer 2: 业务异常处理]
    C --> D[Layer 3: 端点级 try-except]
    D --> E[业务逻辑]

    E -->|抛出异常| F{异常类型?}
    F -->|业务异常| C
    F -->|未知异常| B

    C --> G[返回友好错误提示]
    B --> H[记录日志 + 返回通用错误]

三层防护,各司其职:

  1. 全局异常处理器:兜底所有未处理的异常,记录日志,返回统一格式
  2. 业务异常处理:处理可预期的业务错误,返回友好提示
  3. 端点级 try-except:处理特定接口的特定异常,做细粒度恢复

定义业务异常类

首先,定义一套业务异常类:

# app/core/exceptions.py
from typing import Optional, Any

class AppException(Exception):
    """应用异常基类"""

    def __init__(
        self,
        message: str,
        code: str = "UNKNOWN_ERROR",
        status_code: int = 500,
        details: Optional[Any] = None
    ):
        self.message = message
        self.code = code
        self.status_code = status_code
        self.details = details
        super().__init__(message)


class ValidationError(AppException):
    """数据验证错误"""
    def __init__(self, message: str, details: Any = None):
        super().__init__(message, "VALIDATION_ERROR", 400, details)


class NotFoundError(AppException):
    """资源不存在"""
    def __init__(self, resource: str, resource_id: str):
        super().__init__(
            f"{resource} not found: {resource_id}",
            "NOT_FOUND",
            404,
            {"resource": resource, "id": resource_id}
        )


class GenerationError(AppException):
    """AI 生成错误"""
    def __init__(self, message: str, model: str = None):
        super().__init__(
            message,
            "GENERATION_ERROR",
            500,
            {"model": model}
        )


class ExternalServiceError(AppException):
    """外部服务错误"""
    def __init__(self, service: str, message: str):
        super().__init__(
            f"{service} error: {message}",
            "EXTERNAL_SERVICE_ERROR",
            502,
            {"service": service}
        )

有了这套异常类,错误就有了明确的分类和结构。


全局异常处理器

然后,在 FastAPI 应用中注册异常处理器:

# app/main.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.core.exceptions import AppException
from app.core.logger import logger
import traceback

app = FastAPI()

@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
    """处理业务异常"""

    # 业务异常用 warning 级别
    logger.warning(
        f"业务异常 [{exc.code}]: {exc.message}",
        extra={
            "path": request.url.path,
            "method": request.method,
            "code": exc.code,
            "details": exc.details
        }
    )

    return JSONResponse(
        status_code=exc.status_code,
        content={
            "success": False,
            "error": {
                "code": exc.code,
                "message": exc.message,
                "details": exc.details
            }
        }
    )


@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
    """处理所有未捕获的异常"""

    # 未知异常用 error 级别,记录完整堆栈
    logger.error(
        f"未处理的异常: {type(exc).__name__}: {str(exc)}",
        extra={
            "path": request.url.path,
            "method": request.method,
            "traceback": traceback.format_exc()  # 完整堆栈!
        }
    )

    # 生产环境不暴露内部错误细节
    return JSONResponse(
        status_code=500,
        content={
            "success": False,
            "error": {
                "code": "INTERNAL_ERROR",
                "message": "An internal error occurred. Please try again later."
            }
        }
    )

关键点traceback.format_exc() 会记录完整的堆栈信息。

有了这个,再也不用加 print 去猜异常在哪了。


端点级异常处理

对于特定接口,可以做更细粒度的处理:

# app/api/endpoints/generate.py
from app.core.exceptions import GenerationError, ValidationError

@router.post("/shot-image")
async def generate_shot_image(request: GenerateShotImageRequest):
    """生成分镜图片"""

    # 参数验证
    if not request.prompt.strip():
        raise ValidationError("Prompt cannot be empty")

    try:
        adapter = ImageGenerationAdapterFactory.get_current_adapter()
        result = await adapter.generate_shot_image(
            prompt=request.prompt,
            width=request.width,
            height=request.height
        )

        if not result.get("success"):
            raise GenerationError(
                result.get("error", "Unknown generation error"),
                model=settings.IMAGE_MODEL
            )

        return {"success": True, "data": result}

    except GenerationError:
        raise  # 已经是业务异常,直接抛出

    except torch.cuda.OutOfMemoryError:
        # 特定异常转换为业务异常
        raise GenerationError("GPU out of memory. Please try a smaller image size.")

    except Exception as e:
        # 未知异常,记录后转换为业务异常
        logger.error(f"Unexpected error in generate_shot_image: {e}")
        raise GenerationError(f"Generation failed: {str(e)}")

这样:

  • 参数错误返回 400
  • 显存不足返回 500,但有友好提示
  • 其他异常也会被捕获,不会"消失"

日志配置

小禾选用了 loguru,比标准库的 logging 好用太多:

# app/core/logger.py
from loguru import logger
import sys

# 移除默认处理器
logger.remove()

# 控制台输出(开发环境)
logger.add(
    sys.stdout,
    format="{time:YYYY-MM-DD HH:mm:ss} | "
           "{level:  | "
           "{name}:{function}:{line} | "
           "{message}",
    level="DEBUG"
)

# 文件输出(生产环境)
logger.add(
    "logs/app_{time:YYYY-MM-DD}.log",
    format="{time:YYYY-MM-DD HH:mm:ss} | {level:  {request.method} {request.url.path}")

    start = time.time()

    # 处理请求
    response = await call_next(request)

    # 计算耗时
    duration = time.time() - start

    # 记录请求完成
    logger.info(f"[{request_id}]  POST /api/generate/shot-image
    2024-01-15 14:30:12 | INFO | [a1b2c3d4]  业务 -\> 端点    |
| 日志要完整   | 包含堆栈、请求信息           |
| 错误码标准化  | 定义业务异常枚举            |
| 开发/生产区分 | 开发环境暴露详情            |
| 请求可追踪   | 添加 request\_id      |

***

## 小禾的感悟

    那三小时的排查,
    让我学会一个道理:

    异常不会消失,
    只会被藏起来。

    except pass 是懒惰,
    是给未来的自己挖坑。

    每个异常都有它的意义,
    要么处理它,
    要么记录它,
    要么抛出它。

    但绝不能忽视它。

    全局处理器是保险,
    业务异常是分类,
    日志是证据。

    有了这三样,
    再也不用三小时找 bug 了。

    下次有人说 500,
    我只需要一分钟。

小禾看着清晰的日志输出,心情大好。

三小时的教训,换来了一套完善的异常处理体系。

值了。

***

**下一篇预告:前端传了个 null,后端直接炸了**

> 防御性编程,让你的接口固若金汤。

敬请期待。