一、为什么我们需要一套统一的异常处理方案

咱们平时写Django接口,最头疼的事情之一就是异常处理。你想想,每个接口都有可能出错:参数没传全、数据库查不到数据、权限不够、第三方服务超时……如果每个接口都自己写try-except,那代码里到处是重复的异常分支,而且返回给前端的错误格式五花八门。今天这个接口报错返回一个字符串,明天那个接口返回一个字典,后天再给你来个HTML错误页。前端同学想统一处理错误信息,简直想掀桌子。

这时候就需要一套“统一编排”的方案。说白了,就是咱们提前把各种错误情况分门别类,定义成一个个“懂规矩”的异常类,然后在一个集中的地方统一“接住”它们,再统一转换成规范的JSON响应。这样一来,不管后端怎么出错,前端拿到的都是同一副面孔:一个清晰的错误码,一句能看懂的消息,外加一个标准的HTTP状态码。是不是舒服多了?

接下来,咱们就一步步把这套方案搭起来。我会用纯Python + Django REST Framework(简称DRF)来演示,整个过程中代码会直接跑在Django项目里。

# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# 假设你已经创建了一个Django项目,并且安装了djangorestframework

二、Django内置异常处理机制回顾

在搞自定义方案之前,咱们先看看Django和DRF原生是怎么处理异常的。Django自己有一套异常,比如Http404PermissionDeniedRequestDataTooBig等等。正常情况下,如果在视图里抛出了这些异常,Django会生成对应的HTTP响应(比如404页面,403页面)。对于API接口来说,这种HTML响应显然不太友好。

DRF比Django更进一步,它有一个自带的exception_handler,作用就是捕获视图里抛出的异常,然后转成规范的JSON。DRF默认能处理APIException的子类,比如ValidationErrorAuthenticationFailedNotFound等等。我们来看一下DRF默认的返回结构:

# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# 在DRF的settings中,默认异常处理器是:
# 'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler'

# 当视图抛出NotFound时,响应大致长这样:
# {
#     "detail": "Not found."
# }

看到没有,DRF默认只给一个detail字段。如果咱们想让错误信息更丰富,比如加上错误码、状态码、时间戳,就得自己动手改写这个处理器。这也是咱们今天要做的核心工作。

三、自定义异常类的设计思路

要统一编排,第一步就是设计一个“根异常”。咱们管它叫BizException(业务异常),专门用来表示那些你可以预料到的、业务层面的错误。比如“用户名已被占用”、“订单金额不对”之类的。它需要携带三个关键信息:

  1. code:给前端看的错误码,比如1001代表“参数错误”,1002代表“资源不存在”。
  2. message:给用户看的提示信息,要友好,别甩一个英文栈给人家。
  3. http_status_code:HTTP状态码,比如400、404、409。

咱们还可以让这个异常支持嵌套细节,如果错误需要更精细的字段级别信息,可以用一个details字典塞进去。代码如下:

# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# 新建一个exceptions.py文件,放自定义异常类

class BizException(Exception):
    """
    业务异常基类。
    所有业务相关的异常都应该继承这个类。
    """

    def __init__(
        self,
        code: int,
        message: str,
        http_status_code: int = 400,
        details: dict | list | None = None,
    ):
        self.code = code                    # 业务错误码,例如 10001
        self.message = message              # 用户友好提示
        self.http_status_code = http_status_code  # 对应的HTTP状态码
        self.details = details              # 可选的细节信息
        super().__init__(self.message)

    def to_dict(self):
        """把异常信息转成字典,方便后面序列化"""
        payload = {
            "code": self.code,
            "message": self.message,
            "status_code": self.http_status_code,
        }
        if self.details:
            payload["details"] = self.details
        return payload

有了这个基类,咱们就可以派生出各种各样的具体异常。比如:

# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# 在exceptions.py里继续加

class InvalidArgumentError(BizException):
    """参数不合法"""
    def __init__(self, message="参数有误", details=None):
        super().__init__(
            code=10001,
            message=message,
            http_status_code=400,
            details=details,
        )


class ResourceNotFoundError(BizException):
    """资源不存在"""
    def __init__(self, message="请求的资源不存在", details=None):
        super().__init__(
            code=10002,
            message=message,
            http_status_code=404,
            details=details,
        )


class PermissionForbiddenError(BizException):
    """没有权限执行该操作"""
    def __init__(self, message="没有权限执行此操作", details=None):
        super().__init__(
            code=10003,
            message=message,
            http_status_code=403,
            details=details,
        )

这样一来,咱们在代码里抛异常就很优雅了:直接raise ResourceNotFoundError("订单不存在"),一句话搞定。不需要每个视图里都写return Response(status=404)这种臃肿的代码。

四、DRF异常处理器的协同作战

异常类只是“抛”的那一侧,真正的“接”要靠DRF的异常处理器。默认的处理器不能满足咱们的需求,所以要写一个自定义的custom_exception_handler。这个处理器会接管整个DRF视图抛出的所有异常,包括我们自定义的BizException,也包括DRF自带的异常,甚至还包括所有Python的Exception

咱们需要搞清楚处理顺序:

  1. 先调用DRF自带的exception_handler,让它处理那些DRF已经能处理的东西(比如ValidationErrorNotFound)。
  2. 如果DRF没接住(返回None),说明是别的异常,咱们再一层层去判断:是不是咱们的BizException?是不是Python内置的Exception
  3. 最后统一组织成标准格式返回。

看代码:

# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# 新建一个handlers.py文件

from rest_framework.views import exception_handler as drf_exception_handler
from rest_framework.response import Response
from rest_framework import status
from django.http import Http404
from rest_framework.exceptions import APIException
from .exceptions import BizException
import logging

logger = logging.getLogger(__name__)


def custom_exception_handler(exc, context):
    """
    自定义的DRF异常处理器。
    exc:实际抛出的异常对象
    context:包含request、view等信息的字典
    """
    # 第一步:先让DRF默认的处理器试试,它能处理ValidationError、NotFound等自带异常
    response = drf_exception_handler(exc, context)

    if response is not None:
        # DRF已经处理了,但返回格式是它自带的,咱们需要统一改一下格式
        # 原来的response.data里通常只有detail字段,也可能是字段错误字典
        # 咱们重新包装一下
        error_data = response.data
        # 拼一个标准的统一错误结构
        data = {
            "code": _extract_drf_code(exc),
            "message": _extract_drf_message(exc, error_data),
            "status_code": response.status_code,
            "details": error_data,   # 把原始信息塞进details,供前端调试
        }
        return Response(data=data, status=response.status_code)

    # 第二步:DRF没处理的,看看是不是咱们自定义的业务异常
    if isinstance(exc, BizException):
        return Response(
            data=exc.to_dict(),
            status=exc.http_status_code,
        )

    # 第三步:处理Django自带的Http404、PermissionDenied
    if isinstance(exc, Http404):
        return Response(
            data={
                "code": 10002,
                "message": "资源不存在",
                "status_code": 404,
                "details": str(exc),
            },
            status=404,
        )

    # 第四步:兜底处理所有未知异常(比如TypeError、KeyError之类的)
    # 这样能保证任何异常都不会“白屏”,但要注意记录日志
    logger.error(f"未捕获的异常: {exc}", exc_info=exc)
    return Response(
        data={
            "code": 50000,
            "message": "服务器开小差了,请稍后再试",
            "status_code": 500,
            "details": str(exc) if _is_debug() else None,
        },
        status=500,
    )


def _extract_drf_code(exc):
    """从DRF异常里提取咱们想要的业务码"""
    # 常见情况:ValidationError对应的业务码是10001
    if isinstance(exc, APIException):
        return 10001 if exc.status_code == 400 else exc.status_code * 100
    return 50000


def _extract_drf_message(exc, error_data):
    """从DRF异常里提取用户提示信息"""
    if isinstance(exc, APIException):
        if isinstance(error_data, dict):
            # 比如参数验证错误:{"name": ["这个字段不能为空。"]}
            # 咱们取第一个字段的第一个错误信息,更友好
            for field, errors in error_data.items():
                if isinstance(errors, (list, tuple)):
                    return f"{field}: {errors[0]}"
                return f"{field}: {errors}"
        # 如果是一个字符串(比如detail是字符串)
        return str(exc)
    return str(exc)


def _is_debug():
    """是否开启调试模式,决定要不要把原始异常信息返回给前端"""
    from django.conf import settings
    return settings.DEBUG

光写这个处理器还不行,咱们得让DRF知道用这个,而不是默认的那个。在settings.py里配置:

# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# settings.py

REST_FRAMEWORK = {
    'DEFAULT_RENDERER_CLASSES': [
        'rest_framework.renderers.JSONRenderer',
    ],
    'EXCEPTION_HANDLER': 'myapp.handlers.custom_exception_handler',
    # 其他配置...
}

注意替换成你实际的app路径。到这里,咱们的“协同作战”框架已经搭好了。接下来通过一个实际的接口来看效果。

五、处理DRF内置异常的映射

很多人会忽略一个场景:DRF的ValidationError非常常见,比如用序列化器(Serializer)校验参数不通过时,DRF会抛出ValidationError。咱们的处理器能把它接住,但要注意一件事:默认情况下,DRF会把字段错误直接放在response.data里,而且是一个字典。咱们上面用details字段把原始信息保留了下来,这样前端既能拿到友好的message,又能拿到字段级的错误明细,两者兼顾。

再比如DRF的AuthenticationFailed,它返回的状态码是401,在咱们的_extract_drf_code里,会变成40100这种码。这个码一看就知道是认证问题,挺直观。咱们还可以在映射方法里做更细的定制,比如单独给AuthenticationFailed一个code=20001

# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# 扩展一下_extract_drf_code

def _extract_drf_code(exc):
    from rest_framework.authentication import AuthenticationFailed
    if isinstance(exc, AuthenticationFailed):
        return 20001
    if isinstance(exc, APIException):
        return 10001 if exc.status_code == 400 else exc.status_code * 100
    return 50000

这样一来,DRF的异常和咱们自定义的异常就在一个体系里了。前端只需要看code就能知道错误类型,不需要再猜。

六、实战:一个完整的统一编排示例

光说不练假把式。咱们来造一个小场景:一个简单的订单查询接口。如果订单ID不是数字,就抛InvalidArgumentError;如果订单不存在,就抛ResourceNotFoundError。然后在DRF的APIView里调用。

先定义序列化器和视图:

# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
# 假设app名叫orders

# orders/views.py

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.request import Request
from .exceptions import InvalidArgumentError, ResourceNotFoundError

# 模拟一个订单数据库(真实开发用ORM)
FAKE_ORDERS = {
    1: {"id": 1, "amount": 99.9, "goods_name": "T恤"},
    2: {"id": 2, "amount": 199.9, "goods_name": "牛仔裤"},
}


class OrderDetailView(APIView):
    """订单详情接口:/api/orders/<order_id>/"""

    def get(self, request: Request, order_id):
        # 1. 检查order_id是否是整数
        try:
            order_id = int(order_id)
        except (TypeError, ValueError):
            # 不是整数,抛自定义业务异常
            raise InvalidArgumentError(
                message="订单号必须是一个整数",
                details={"order_id": "传入了非数字的订单号"},
            )

        # 2. 查询订单
        order = FAKE_ORDERS.get(order_id)
        if order is None:
            # 订单不存在
            raise ResourceNotFoundError(
                message="找不到这笔订单",
                details={"order_id": order_id},
            )

        # 3. 正常返回
        return Response(data={"code": 0, "data": order, "message": "success"})

这个视图里没有一行try-except,是不是特别清爽?异常全被抛出去了,最后会统一到咱们的处理器里。咱们测试一下不同请求会得到什么结果。假设项目跑在localhost:8000

第一个请求:GET /api/orders/abc/

响应会是这样:

{
    "code": 10001,
    "message": "订单号必须是一个整数",
    "status_code": 400,
    "details": {
        "order_id": "传入了非数字的订单号"
    }
}

第二个请求:GET /api/orders/999/

响应:

{
    "code": 10002,
    "message": "找不到这笔订单",
    "status_code": 404,
    "details": {
        "order_id": 999
    }
}

第三个请求:GET /api/orders/1/

响应:

{
    "code": 0,
    "data": {
        "id": 1,
        "amount": 99.9,
        "goods_name": "T恤"
    },
    "message": "success"
}

注意成功时的code: 0并不是异常,只是咱们响应体的一个业务标志。这样整个API的响应格式就统一了:成功时有data,失败时有details,永远都有codemessage。前端只需要判断code是否为0就能决定是否进入错误分支,非常方便。

七、应用场景与优缺点分析

7.1 应用场景

  • 前后端分离项目:后端只提供JSON API,前端需要稳定的错误结构,这套方案是标配。
  • 微服务间调用:服务A调服务B,B返回的标准错误码能帮助A快速定位是哪种错误,比如参数问题还是服务不可用。
  • 日志与监控:在自定义handler里统一打日志,把异常栈、请求路径、用户信息都记录进去,后续排查问题特别方便。
  • 第三方接入:你给别人提供API,对方需要根据错误码做自动化处理,一套规范的错误体系能降低沟通成本。

7.2 技术优点

  • 代码简洁:业务视图里不用再写大量的try-except,逻辑更聚焦于正常流程。
  • 返回统一:所有的错误都遵循一个模子,前端处理逻辑简单到飞起。
  • 可扩展性强:想加一种新错误,只需要新写一个继承BizException的类,不改动已有代码。
  • 安全:兜底异常不会把Python内部堆栈直接暴露给用户,避免信息泄露。

7.3 技术缺点

  • 学习成本:团队新人需要理解整套异常继承体系,否则可能不知道该抛什么异常。
  • 过度抽象风险:如果项目很小,只有两三个接口,搞一堆异常类反而显得“杀鸡用牛刀”。
  • 错误码需要维护:当错误码越来越多,如果没有文档或统一登记表,会变得难以管理。

八、注意事项与避坑指南

  • 不要吞异常:在自定义handler中,对于未知异常一定要记录日志,然后兜底返回500。不要为了“友好”把异常细节全部掩盖,否则线上出了问题你连原因都查不到。
  • 一定要调用DRF的默认handler:咱们的custom_exception_handler第一行就是调用drf_exception_handler,这样DRF的APIException不会被咱们误伤。如果你直接把所有异常按BizException处理,可能会丢失很多DRF原本已经处理好的逻辑。
  • DEBUG模式下适当暴露细节:开发环境可以返回details: str(exc),生产环境直接写details: null。用之前写的_is_debug()来控制,别把敏感信息漏出去。
  • 注意异常链上下文:当你在except块里手动抛出自定义异常时,建议使用raise ... from exc,否则看不到原始异常栈。比如:
# 技术栈:Python 3.10 / Django 4.2 / Django REST Framework 3.14
try:
    do_something()
except DatabaseError as exc:
    # 保留原始异常链,方便排查
    raise BizException(
        code=30001,
        message="数据库操作失败",
        http_status_code=500,
    ) from exc
  • 别把HttpResponse和DRF的Response混了:如果你的项目里有普通Django视图(非DRF),那些视图抛出的异常不会被DRF的handler捕获,需要另外处理,比如中间件统一处理。咱们这里的方案是针对DRF视图的。
  • 错误码设计要有规律:比如前两位代表模块(10:用户模块,20:订单模块),后三位代表具体错误。千万别随手乱编。

九、总结

通过自定义异常类和DRF异常处理器的配合,咱们把原本七零八落的异常处理变成了一套“流水线”。从抛异常到接异常,再到生成标准化响应,每一步都清清楚楚。这不仅让代码更好看,更让前后端协作的双方都松了一口气。

当然,没有一个方案是万能的,这套编排也并非适合所有项目。但如果你正在做一个API比较多、错误类型复杂、需要长期演进的项目,花点时间把这套基础设施打好,绝对是值得的。咱们写代码,不就是图个“省心”吗?早点统一规划,后面就能少加班,多陪陪家人。希望今天的分享能给你的项目带来一点灵感。