保留正确状态码的 Flask 异常处理

原作者:Pallets / Flask 文档贡献者。本文完整译写自 Flask 3.1.x 官方文档 Handling Application Errors,核对日期为 2026-10-05。文中标为“编者更正”或“编者补充”的部分与原例有所不同;代码只做静态审核,没有启动服务或发送测试请求。

即使应用代码没有缺陷,生产环境也会发生异常:客户端可能提前断开,而应用还在读请求;数据库或后端可能过载;文件系统可能写满;硬盘可能损坏;依赖库可能有错误;服务器到其他系统的网络也可能中断。Flask 在生产模式下遇到未处理异常,默认会显示简单错误页,并把异常写入日志。更合适的处理方式,是在可观测性、HTTP 语义和面向用户的提示之间做好分工。

Flask 捕获异常后先按状态码,再按最具体异常类选择处理器;HTTP异常应保留状态和响应头,未处理的普通异常转为500;路由404和405需要应用层处理。
错误处理器选择与响应保留的关系。原创流程图,非请求测试截图。

一、收集错误,但先确认数据会去哪里

只靠报错邮件,可能在大量用户触发同一错误时淹没收件箱;日志文件又可能长期无人查看。原文推荐 Sentry 一类错误聚合工具,它可以合并重复错误、采集堆栈与局部变量,并根据新错误或发生频率发送通知。Sentry 有可查看源码的项目及托管服务,具体服务条款与额度应在采用时核对。

原文安装和初始化示例如下:

pip install 'sentry-sdk[flask]'
import sentry_sdk
from sentry_sdk.integrations.flask import FlaskIntegration

sentry_sdk.init('YOUR_DSN_HERE', integrations=[FlaskIntegration()])

YOUR_DSN_HERE 是占位值,应替换为自己的 Sentry 目标 DSN;它不是本文发现的实际秘密。原文说明配置之后,导致 500 的异常可自动上报。相关扩展资料包括 Python SDK 文档与 Flask 集成文档;RQ、Celery 等任务队列也有相似集成方式。

编者补充:SDK 上报是向外部或自建系统发送数据,堆栈、局部变量和请求信息可能含凭证或个人资料。启用前应确认目标 DSN、访问权限、保留期限、费用与脱敏规则;不要直接复制初始化片段就向不明项目上报。本文没有安装 SDK,也没有发送任何错误事件。安装命令为避免 shell 通配符解释而加了引号,这是与原例的格式性差异。

二、错误处理器返回什么,就必须包含正确状态

HTTP 400–499 通常表示请求或所请求资源的问题,500–599 表示服务器或应用一侧的问题。错误处理器类似视图函数:视图处理匹配的请求,错误处理器处理指定类型的异常,并接收该异常实例。

注册了 400 处理器,并不代表返回值会自动带上 400。只返回字符串或模板,仍可能得到默认的成功状态。处理器必须在返回值中明确状态码,或返回已经包含正确状态的响应对象。

以下片段假设应用已经创建:

from flask import Flask
app = Flask(__name__)

各小节是独立的注册示例,不应不加区分地全部堆进一个应用;重复注册同一个错误类型,可能替换先前的处理策略。

按状态码或异常类注册

import werkzeug.exceptions

@app.errorhandler(werkzeug.exceptions.BadRequest)
def handle_bad_request(e):
    return 'bad request!', 400

# 也可以不使用装饰器,改为稍后注册
app.register_error_handler(400, handle_bad_request)

BadRequest 等 HTTPException 子类与对应数字码可以互换注册,因为例如 BadRequest.code == 400。但 Werkzeug 不认识的状态码不能直接按数字注册。应定义带有相应 code 的异常类,并注册、抛出该类。原文以 507 为例:

class InsufficientStorage(werkzeug.exceptions.HTTPException):
    code = 507
    description = 'Not enough storage space.'

# 编者补齐:原例引用了 handle_507,但没有定义它
def handle_507(e):
    return e.get_response()

app.register_error_handler(InsufficientStorage, handle_507)

# 在请求处理流程中确实需要报告该错误时:
# raise InsufficientStorage()

最后一行在原文中是直接 raise InsufficientStorage();本文改为注释中的调用位置示意,避免读者把模块导入阶段的抛出当作完整应用写法。处理器也可以注册普通 Python 异常类,不限于 HTTP 异常。

Flask 如何挑选处理器

请求处理中捕获到异常后,Flask 先按状态码查找;若没有对应码的处理器,再沿异常类继承层次查找,选择最具体的一项。比如同时注册了 ConnectionError 与 ConnectionRefusedError,后者实例会交给后者的处理器。

未匹配到处理器时,HTTP 异常使用其默认错误响应;其他未处理异常转为通用 500。访问未注册路由会触发 404,访问路由不允许的方法会触发 405;它们都属于 Werkzeug 的 HTTP 异常体系。

如果已经确定某个蓝图在处理请求,蓝图上的处理器通常优先于应用级处理器。不过路由阶段的 404 发生时,还无法确定请求属于哪个蓝图,不能指望蓝图的 404 处理器兜住不存在的 URL。

三、通用处理器不要丢掉 HTTP 语义

把所有 HTTP 错误转换为 JSON

为 HTTPException 注册通用处理器,可以统一把默认 HTML 错误转换成 JSON。但它也会捕获路由产生的 404、405,而不只是你主动抛出的错误。应从异常已有的响应开始,保留状态码和响应头,再改正文:

from flask import json
from werkzeug.exceptions import HTTPException

@app.errorhandler(HTTPException)
def handle_http_exception(e):
    response = e.get_response()
    response.data = json.dumps({
        "code": e.code,
        "name": e.name,
        "description": e.description,
    })
    response.content_type = "application/json"
    return response

例如 405 的 Allow 响应头也是错误语义的一部分。直接拼出一个新的 JSON 对象再只附上状态码,可能丢掉这些信息。错误描述也应只放可对客户端公开的内容,不能把堆栈、查询语句或秘密放进 description。

兜住普通异常时,放行 HTTP 异常

注册 Exception 相当于大范围的 except Exception:,会覆盖所有尚未被更具体处理器接住的异常,包括 HTTP 异常。通常应优先处理具体类型;若确实需要最后一道兜底,可以先放行 HTTP 异常:

from flask import render_template
from werkzeug.exceptions import HTTPException

@app.errorhandler(Exception)
def handle_unexpected_exception(e):
    if isinstance(e, HTTPException):
        return e

    # 编者补充:在受控日志中留证,不把异常细节传给页面
    app.logger.exception("Unhandled application exception")
    return render_template("500_generic.html"), 500

与原例的差异:原文向 500_generic.html 传入 e=e,本文去掉它并增加日志调用,减少模板意外显示内部异常的机会。日志本身也应限制访问和脱敏。若同时存在 HTTPException 与 Exception 两类处理器,前者更具体,HTTP 异常会优先进入前者。

四、未处理异常、500 页与调试模式

普通异常没有被处理器接住时,会形成 500 响应。如果注册了 InternalServerError 或 500 处理器,Flask 会调用它。自 Flask 1.1.0 起,传入的是 InternalServerError 实例,不是原始异常本身;需要原异常时,应读取 e.original_exception。显式抛出的 500 也会进入这一处理器,原异常字段不一定存在实际异常值。

调试模式不使用自定义 500 页,而会显示交互调试器。因此不能用调试模式下的页面表现判断生产 500 页是否有效,也不应把交互调试器暴露给生产访问者。

五、自定义错误页面

abort() 可以在请求处理过程中终止流程并触发指定 HTTP 错误。以用户资料页为例:查询参数里没有用户名时返回 400;提供了用户名但找不到用户时返回 404。

from flask import abort, render_template, request

@app.route("/profile")
def user_profile():
    username = request.args.get("username")
    if username is None:
        abort(400)

    user = get_user(username=username)
    if user is None:
        abort(404)

    return render_template("profile.html", user=user)

编者更正:源文写成 request.arg.get(...),实际应为 request.args.get(...)。成功请求形如 /profile?username=jack。get_user() 与 profile.html 是业务代码和模板,原文没有提供实现;这个片段不是独立应用。它也只拒绝参数不存在的情况,空字符串、格式校验、查询授权和数据访问控制应按业务补全。

404 页面注册时显式返回 404:

from flask import render_template

@app.errorhandler(404)
def page_not_found(e):
    return render_template('404.html'), 404

如果采用应用工厂,则可以稍后注册:

from flask import Flask, render_template

def page_not_found(e):
    return render_template('404.html'), 404

def create_app(config_filename):
    app = Flask(__name__)
    app.register_error_handler(404, page_not_found)
    return app

这是原文的工厂片段,参数 config_filename 在片段内没有被读取,不代表配置已经加载。模板可以继承站点自己的布局:

{% extends "layout.html" %}
{% block title %}Page Not Found{% endblock %}
{% block body %}
  <h1>Page Not Found</h1>
  <p>你要找的内容不在这里。</p>
  <p><a href="{{ url_for('index') }}">返回首页</a></p>
{% endblock %}

这要求存在 layout.html、相应模板块以及名为 index 的端点。本文将源例提示语译为中文,并补齐了段落闭合标签。

500 页面及注册方式

仅换一个标题,不会给用户多少额外帮助。可以制作简单且不泄露内部细节的 500.html:

{% extends "layout.html" %}
{% block title %}Internal Server Error{% endblock %}
{% block body %}
  <h1>Internal Server Error</h1>
  <p>抱歉,处理请求时发生了内部错误。</p>
  <p><a href="{{ url_for('index') }}">返回首页</a></p>
{% endblock %}

在应用上注册:

from flask import render_template

@app.errorhandler(500)
def internal_server_error(e):
    return render_template('500.html'), 500

应用工厂的等价结构:

from flask import Flask, render_template

def internal_server_error(e):
    return render_template('500.html'), 500

def create_app():
    app = Flask(__name__)
    app.register_error_handler(500, internal_server_error)
    return app

蓝图也可以用装饰器或显式注册:

from flask import Blueprint, render_template

blog = Blueprint('blog', __name__)

@blog.errorhandler(500)
def internal_server_error(e):
    return render_template('500.html'), 500

# 或采用显式注册方式
blog.register_error_handler(500, internal_server_error)

原蓝图片段只展示了 Blueprint 的导入,本文补出使用到的 render_template。蓝图仍需在应用中注册;这里重点是错误处理器的注册位置。

六、蓝图的 404 / 405 边界

蓝图里的 404、405 处理器,通常只会处理该蓝图视图函数主动 raise 或 abort 的相应异常。访问无效 URL 等路由错误,不会自动交给某个蓝图,因为蓝图并不“拥有”一整片尚未匹配成功的 URL 空间。

如果希望按路径前缀给出不同响应,应在应用层判断 request.path。原文的 404 策略如下:

from flask import request, render_template

@app.errorhandler(404)
def page_not_found(e):
    if request.path.startswith('/blog/'):
        return render_template("blog/404.html"), 404
    return render_template("404.html"), 404

原文还给出 API 路径返回 JSON、其他路径返回 HTML 的 405 例子,但仅返回新正文和 405 会丢掉原异常的响应头。下面是编者修正版,保留 Allow 等头:

from flask import json, request, render_template

@app.errorhandler(405)
def method_not_allowed(e):
    response = e.get_response()
    if request.path.startswith('/api/'):
        response.data = json.dumps({"message": "Method Not Allowed"})
        response.content_type = "application/json"
    else:
        response.data = render_template("405.html")
        response.content_type = "text/html; charset=utf-8"
    return response

前缀规则只是响应呈现策略,不是鉴权边界;例如 /api/ 与 /api 是否都匹配,应由实际路由设计决定。

七、让 API 返回可读 JSON 错误

默认 HTTP 异常正文是 HTML,对 API 客户端不一定方便。原文先展示 abort() 的 description 如何进入 JSON 响应:

from flask import abort, jsonify

@app.errorhandler(404)
def resource_not_found(e):
    return jsonify(error=str(e)), 404

@app.route("/cheese")
def get_one_cheese():
    resource = get_resource()
    if resource is None:
        abort(404, description="Resource not found")
    return jsonify(resource)

get_resource() 仍是待接入的业务函数。这里给定的是 404;如果推广为所有 HTTP 异常的通用转换,应回到前面 e.get_response() 的方案以保留响应头。

定义业务异常

当 API 需要可读消息、指定状态码和附加字段时,可以定义自己的异常类型:

from flask import jsonify, request

class InvalidAPIUsage(Exception):
    status_code = 400

    def __init__(self, message, status_code=None, payload=None):
        super().__init__()
        self.message = message
        if status_code is not None:
            self.status_code = status_code
        self.payload = payload

    def to_dict(self):
        rv = dict(self.payload or ())
        rv['message'] = self.message
        return rv

@app.errorhandler(InvalidAPIUsage)
def invalid_api_usage(e):
    return jsonify(e.to_dict()), e.status_code

@app.route("/api/user")
def user_api():
    user_id = request.args.get("user_id")
    if not user_id:
        raise InvalidAPIUsage("No user id provided!")
    user = get_user(user_id=user_id)
    if not user:
        raise InvalidAPIUsage("No such user!", status_code=404)
    return jsonify(user.to_dict())

编者更正:源文 def user_api(user_id) 要求路由注入一个参数,但 /api/user 并没有该路由变量;本稿改为无参数函数,并把第二处 request.arg 改成 request.args。请求示例为 /api/user?user_id=420。

视图现在可以抛出 InvalidAPIUsage,还可以通过字典 payload 附加上下文。不过这里的 get_user()、用户对象和访问控制并未实现,不能声称接口已安全可用。实际项目应校验用户 ID、使用安全的数据访问方式、核实访问权限,并限制 user.to_dict() 与错误 payload 的可公开字段;状态码也应由服务端逻辑控制。

八、日志与排错

原文最后分别指向 Logging 与 Debugging Application Errors。错误响应解决客户端如何理解结果,日志解决维护者如何追查原因,两者不能互相替代。生产日志应保留足够上下文,但不向客户端公开堆栈,也不让调试器承担生产错误页的角色。

来源、许可和审核范围

来源:Flask 官方文档:Handling Application Errors;Copyright 2010 Pallets。适用 BSD-3-Clause License,版权、条件与免责声明全文保留在下方。本文是带明示修正的中文译写,不表示 Pallets 背书。

本次静态审核发现并修正了两处 request.arg、API 路由签名、405 响应头丢失与异常信息传入通用模板的风险;补齐少量必要导入及 507 处理函数。没有执行代码、调用业务系统或发送 Sentry 数据。其余未发现问题不等于不存在漏洞,业务查询、模板和权限逻辑仍需由应用自行实现与验证。

原始版权与许可全文

Copyright 2010 Pallets

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

1.  Redistributions of source code must retain the above copyright
    notice, this list of conditions and the following disclaimer.

2.  Redistributions in binary form must reproduce the above copyright
    notice, this list of conditions and the following disclaimer in the
    documentation and/or other materials provided with the distribution.

3.  Neither the name of the copyright holder nor the names of its
    contributors may be used to endorse or promote products derived from
    this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容