定制 Django REST Framework 的异常处理

今天借助修复其它 Bug 的机会,对项目中 REST API 的异常处理机制进行了优化。

项目的 REST API 是基于 Django REST Framework 实现的,之前已经按照其文档的说明,对 EXCEPTION_HANDLER 进行了定制,用统一的 JSON 数据格式来输出异常错误信息,包括 500 错误。没有定制时,对于系统所有未特殊处理的异常,Django REST Framework 的处理方法是继续向上抛出,而不是像其它情况那样处理成自己封装的 Exception Response。这样的话,客户端得到的响应是 Django 框架的 500 错误页面,不便于客户端的统一处理。

自定义的异常处理函数如下:

from rest_framework.views import exception_handler

def my_api_exception_handler(exc):
    # Call REST framework's default exception handler first to get the standard error response.
    response = exception_handler(exc)

    # handle 500 errors
    if response is None:
        if isinstance(exc, APIException):
            detail = exc.detail
        else:
            detail = exc.message if settings.DEBUG else 'Internal Server Error'
        response = Response({'detail': detail}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

    return response

这里先调用框架默认的异常处理函数(Line 5),当这个函数返回的 response 对象为 None 时(Line 8),说明异常没有被它处理。这时我们就自己生成一个 status 状态码为 500 的 Response 对象(Line 13),而不是返回 None 。

自定义的异常处理函数必须在 settings 里启用,如下

REST_FRAMEWORK = {
    'EXCEPTION_HANDLER': 'my_project.my_app.utils. my_api_exception_handler'
}

今天的改进是对异常处理函数中日志的改进。原来的日志记录在发生 500 的错误,就记录一条 Error 日志:

logger.error("api view 500 error: %s >>>\n %s", str(exc), traceback.format_exc())

在生产环境下,这条日志会产生一封通知邮件,但效果并不好,异常堆栈变成了一长串字符被塞进了邮件的主题里,也没有当前 Request 的上下文信息,很难从中分析定位错误。

改进后的日志语句被挪到了 APIView 的 handle_exception 方法里:

def handle_exception(self, exc):
    resp = super(GenericAPIView, self).handle_exception(exc)
    if resp.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR:
        logger.error("API View 500 error: %s", self.request.path,
                     exc_info=sys.exc_info(),
                     extra={
                         'status_code': 500,
                         'request': self.request
                     })
    return resp

可以看到,异常信息和当前请求信息都被记录了( Line 5 和 Line 8)。exc_info 和 extra 参数的说明见 Python 标准库文档,这里也参考了 Django 中 AdminEmailHandler 类的实现。

以上。