all teardown callbacks are called despite errors

This commit is contained in:
David Lord 2026-02-19 19:41:50 -08:00
parent 7b0088693e
commit fbb6f0bc4c
No known key found for this signature in database
GPG key ID: 43368A7AA8CC5926
10 changed files with 195 additions and 81 deletions

View file

@ -14,6 +14,7 @@ Unreleased
If subclasses were overriding these methods, the old signature is detected, If subclasses were overriding these methods, the old signature is detected,
shows a deprecation warning, and will continue to work during the shows a deprecation warning, and will continue to work during the
deprecation period. :issue:`5815` deprecation period. :issue:`5815`
- All teardown callbacks are called, even if any raise an error. :pr:`5928`
- The ``should_ignore_error`` is deprecated. Handle errors as needed in - The ``should_ignore_error`` is deprecated. Handle errors as needed in
teardown handlers instead. :issue:`5816` teardown handlers instead. :issue:`5816`
- ``template_filter``, ``template_test``, and ``template_global`` decorators - ``template_filter``, ``template_test``, and ``template_global`` decorators

View file

@ -163,7 +163,8 @@ The teardown callbacks are called by the context when it is popped. They are
called even if there is an unhandled exception during dispatch. They may be called even if there is an unhandled exception during dispatch. They may be
called multiple times in some test scenarios. This means there is no guarantee called multiple times in some test scenarios. This means there is no guarantee
that any other parts of the request dispatch have run. Be sure to write these that any other parts of the request dispatch have run. Be sure to write these
functions in a way that does not depend on other callbacks and will not fail. functions in a way that does not depend on other callbacks. All callbacks are
called even if any raise an error.
How the Context Works How the Context Works

View file

@ -36,6 +36,7 @@ from .globals import app_ctx
from .globals import g from .globals import g
from .globals import request from .globals import request
from .globals import session from .globals import session
from .helpers import _CollectErrors
from .helpers import get_debug_flag from .helpers import get_debug_flag
from .helpers import get_flashed_messages from .helpers import get_flashed_messages
from .helpers import get_load_dotenv from .helpers import get_load_dotenv
@ -1430,16 +1431,25 @@ class Flask(App):
:param exc: An unhandled exception raised while dispatching the request. :param exc: An unhandled exception raised while dispatching the request.
Passed to each teardown function. Passed to each teardown function.
.. versionchanged:: 3.2
All callbacks are called rather than stopping on the first error.
.. versionchanged:: 0.9 .. versionchanged:: 0.9
Added the ``exc`` argument. Added the ``exc`` argument.
""" """
collect_errors = _CollectErrors()
for name in chain(ctx.request.blueprints, (None,)): for name in chain(ctx.request.blueprints, (None,)):
if name in self.teardown_request_funcs: if name in self.teardown_request_funcs:
for func in reversed(self.teardown_request_funcs[name]): for func in reversed(self.teardown_request_funcs[name]):
with collect_errors:
self.ensure_sync(func)(exc) self.ensure_sync(func)(exc)
with collect_errors:
request_tearing_down.send(self, _async_wrapper=self.ensure_sync, exc=exc) request_tearing_down.send(self, _async_wrapper=self.ensure_sync, exc=exc)
collect_errors.raise_any("Errors during request teardown")
def do_teardown_appcontext( def do_teardown_appcontext(
self, ctx: AppContext, exc: BaseException | None = None self, ctx: AppContext, exc: BaseException | None = None
) -> None: ) -> None:
@ -1452,13 +1462,22 @@ class Flask(App):
:param exc: An unhandled exception raised while the context was active. :param exc: An unhandled exception raised while the context was active.
Passed to each teardown function. Passed to each teardown function.
.. versionchanged:: 3.2
All callbacks are called rather than stopping on the first error.
.. versionadded:: 0.9 .. versionadded:: 0.9
""" """
collect_errors = _CollectErrors()
for func in reversed(self.teardown_appcontext_funcs): for func in reversed(self.teardown_appcontext_funcs):
with collect_errors:
self.ensure_sync(func)(exc) self.ensure_sync(func)(exc)
with collect_errors:
appcontext_tearing_down.send(self, _async_wrapper=self.ensure_sync, exc=exc) appcontext_tearing_down.send(self, _async_wrapper=self.ensure_sync, exc=exc)
collect_errors.raise_any("Errors during app teardown")
def app_context(self) -> AppContext: def app_context(self) -> AppContext:
"""Create an :class:`.AppContext`. When the context is pushed, """Create an :class:`.AppContext`. When the context is pushed,
:data:`.current_app` and :data:`.g` become available. :data:`.current_app` and :data:`.g` become available.

View file

@ -10,6 +10,7 @@ from werkzeug.routing import MapAdapter
from . import typing as ft from . import typing as ft
from .globals import _cv_app from .globals import _cv_app
from .helpers import _CollectErrors
from .signals import appcontext_popped from .signals import appcontext_popped
from .signals import appcontext_pushed from .signals import appcontext_pushed
@ -482,16 +483,26 @@ class AppContext:
if self._push_count > 0: if self._push_count > 0:
return return
try: collect_errors = _CollectErrors()
if self._request is not None: if self._request is not None:
with collect_errors:
self.app.do_teardown_request(self, exc) self.app.do_teardown_request(self, exc)
with collect_errors:
self._request.close() self._request.close()
finally:
with collect_errors:
self.app.do_teardown_appcontext(self, exc) self.app.do_teardown_appcontext(self, exc)
_cv_app.reset(self._cv_token) _cv_app.reset(self._cv_token)
self._cv_token = None self._cv_token = None
with collect_errors:
appcontext_popped.send(self.app, _async_wrapper=self.app.ensure_sync) appcontext_popped.send(self.app, _async_wrapper=self.app.ensure_sync)
collect_errors.raise_any("Errors during context teardown")
def __enter__(self) -> te.Self: def __enter__(self) -> te.Self:
self.push() self.push()
return self return self

View file

@ -7,6 +7,7 @@ import typing as t
from datetime import datetime from datetime import datetime
from functools import cache from functools import cache
from functools import update_wrapper from functools import update_wrapper
from types import TracebackType
import werkzeug.utils import werkzeug.utils
from werkzeug.exceptions import abort as _wz_abort from werkzeug.exceptions import abort as _wz_abort
@ -636,3 +637,34 @@ def _split_blueprint_path(name: str) -> list[str]:
out.extend(_split_blueprint_path(name.rpartition(".")[0])) out.extend(_split_blueprint_path(name.rpartition(".")[0]))
return out return out
class _CollectErrors:
"""A context manager that records and silences an error raised within it.
Used to run all teardown functions, then raise any errors afterward.
"""
def __init__(self) -> None:
self.errors: list[BaseException] = []
def __enter__(self) -> None:
pass
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> bool:
if exc_val is not None:
self.errors.append(exc_val)
return True
def raise_any(self, message: str) -> None:
"""Raise if any errors were collected."""
if self.errors:
if sys.version_info >= (3, 11):
raise BaseExceptionGroup(message, self.errors) # noqa: F821
else:
raise self.errors[0]

View file

@ -1,7 +1,10 @@
import sys
import pytest import pytest
import flask import flask
from flask.globals import app_ctx from flask.globals import app_ctx
from flask.testing import FlaskClient
def test_basic_url_generation(app): def test_basic_url_generation(app):
@ -208,3 +211,55 @@ def test_clean_pop(app):
assert called == ["flask_test", "TEARDOWN"] assert called == ["flask_test", "TEARDOWN"]
assert not flask.current_app assert not flask.current_app
def test_robust_teardown(app: flask.Flask, client: FlaskClient) -> None:
count = 0
@app.teardown_request
def request_teardown(e: Exception | None) -> None:
nonlocal count
count += 1
raise ValueError("request_teardown")
@app.teardown_appcontext
def app_teardown(e: Exception | None) -> None:
nonlocal count
count += 1
raise ValueError("app_teardown")
@app.get("/")
def index() -> str:
return ""
def request_signal(sender: flask.Flask, exc: Exception | None) -> None:
nonlocal count
count += 1
raise ValueError("request_signal")
def app_signal(sender: flask.Flask, exc: Exception | None) -> None:
nonlocal count
count += 1
raise ValueError("app_signal")
with (
flask.request_tearing_down.connected_to(request_signal, app),
flask.appcontext_tearing_down.connected_to(app_signal, app),
):
if sys.version_info >= (3, 11):
with pytest.raises(ExceptionGroup, match="context teardown") as exc_info: # noqa: F821
client.get()
assert len(exc_info.value.exceptions) == 2
eg1, eg2 = exc_info.value.exceptions
assert isinstance(eg1, ExceptionGroup) # noqa: F821
assert "request teardown" in eg1.message
assert len(eg1.exceptions) == 2
assert isinstance(eg2, ExceptionGroup) # noqa: F821
assert "app teardown" in eg2.message
assert len(eg2.exceptions) == 2
else:
with pytest.raises(ValueError, match="request_teardown"):
client.get()
assert count == 4

View file

@ -1420,20 +1420,21 @@ def test_url_for_passes_special_values_to_build_error_handler(app):
def test_static_files(app, client): def test_static_files(app, client):
rv = client.get("/static/index.html") with client.get("/static/index.html") as rv:
assert rv.status_code == 200 assert rv.status_code == 200
assert rv.data.strip() == b"<h1>Hello World!</h1>" assert rv.data.strip() == b"<h1>Hello World!</h1>"
with app.test_request_context(): with app.test_request_context():
assert flask.url_for("static", filename="index.html") == "/static/index.html" assert (
rv.close() flask.url_for("static", filename="index.html") == "/static/index.html"
)
def test_static_url_path(): def test_static_url_path():
app = flask.Flask(__name__, static_url_path="/foo") app = flask.Flask(__name__, static_url_path="/foo")
app.testing = True app.testing = True
rv = app.test_client().get("/foo/index.html")
with app.test_client().get("/foo/index.html") as rv:
assert rv.status_code == 200 assert rv.status_code == 200
rv.close()
with app.test_request_context(): with app.test_request_context():
assert flask.url_for("static", filename="index.html") == "/foo/index.html" assert flask.url_for("static", filename="index.html") == "/foo/index.html"
@ -1442,9 +1443,9 @@ def test_static_url_path():
def test_static_url_path_with_ending_slash(): def test_static_url_path_with_ending_slash():
app = flask.Flask(__name__, static_url_path="/foo/") app = flask.Flask(__name__, static_url_path="/foo/")
app.testing = True app.testing = True
rv = app.test_client().get("/foo/index.html")
with app.test_client().get("/foo/index.html") as rv:
assert rv.status_code == 200 assert rv.status_code == 200
rv.close()
with app.test_request_context(): with app.test_request_context():
assert flask.url_for("static", filename="index.html") == "/foo/index.html" assert flask.url_for("static", filename="index.html") == "/foo/index.html"
@ -1452,25 +1453,25 @@ def test_static_url_path_with_ending_slash():
def test_static_url_empty_path(app): def test_static_url_empty_path(app):
app = flask.Flask(__name__, static_folder="", static_url_path="") app = flask.Flask(__name__, static_folder="", static_url_path="")
rv = app.test_client().open("/static/index.html", method="GET")
with app.test_client().open("/static/index.html", method="GET") as rv:
assert rv.status_code == 200 assert rv.status_code == 200
rv.close()
def test_static_url_empty_path_default(app): def test_static_url_empty_path_default(app):
app = flask.Flask(__name__, static_folder="") app = flask.Flask(__name__, static_folder="")
rv = app.test_client().open("/static/index.html", method="GET")
with app.test_client().open("/static/index.html", method="GET") as rv:
assert rv.status_code == 200 assert rv.status_code == 200
rv.close()
def test_static_folder_with_pathlib_path(app): def test_static_folder_with_pathlib_path(app):
from pathlib import Path from pathlib import Path
app = flask.Flask(__name__, static_folder=Path("static")) app = flask.Flask(__name__, static_folder=Path("static"))
rv = app.test_client().open("/static/index.html", method="GET")
with app.test_client().open("/static/index.html", method="GET") as rv:
assert rv.status_code == 200 assert rv.status_code == 200
rv.close()
def test_static_folder_with_ending_slash(): def test_static_folder_with_ending_slash():
@ -1487,9 +1488,10 @@ def test_static_folder_with_ending_slash():
def test_static_route_with_host_matching(): def test_static_route_with_host_matching():
app = flask.Flask(__name__, host_matching=True, static_host="example.com") app = flask.Flask(__name__, host_matching=True, static_host="example.com")
c = app.test_client() c = app.test_client()
rv = c.get("http://example.com/static/index.html")
with c.get("http://example.com/static/index.html") as rv:
assert rv.status_code == 200 assert rv.status_code == 200
rv.close()
with app.test_request_context(): with app.test_request_context():
rv = flask.url_for("static", filename="index.html", _external=True) rv = flask.url_for("static", filename="index.html", _external=True)
assert rv == "http://example.com/static/index.html" assert rv == "http://example.com/static/index.html"

View file

@ -184,12 +184,12 @@ def test_templates_and_static(test_apps):
assert rv.data == b"Hello from the Admin" assert rv.data == b"Hello from the Admin"
rv = client.get("/admin/index2") rv = client.get("/admin/index2")
assert rv.data == b"Hello from the Admin" assert rv.data == b"Hello from the Admin"
rv = client.get("/admin/static/test.txt")
with client.get("/admin/static/test.txt") as rv:
assert rv.data.strip() == b"Admin File" assert rv.data.strip() == b"Admin File"
rv.close()
rv = client.get("/admin/static/css/test.css") with client.get("/admin/static/css/test.css") as rv:
assert rv.data.strip() == b"/* nested file */" assert rv.data.strip() == b"/* nested file */"
rv.close()
# try/finally, in case other tests use this app for Blueprint tests. # try/finally, in case other tests use this app for Blueprint tests.
max_age_default = app.config["SEND_FILE_MAX_AGE_DEFAULT"] max_age_default = app.config["SEND_FILE_MAX_AGE_DEFAULT"]
@ -198,10 +198,10 @@ def test_templates_and_static(test_apps):
if app.config["SEND_FILE_MAX_AGE_DEFAULT"] == expected_max_age: if app.config["SEND_FILE_MAX_AGE_DEFAULT"] == expected_max_age:
expected_max_age = 7200 expected_max_age = 7200
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = expected_max_age app.config["SEND_FILE_MAX_AGE_DEFAULT"] = expected_max_age
rv = client.get("/admin/static/css/test.css")
with client.get("/admin/static/css/test.css") as rv:
cc = parse_cache_control_header(rv.headers["Cache-Control"]) cc = parse_cache_control_header(rv.headers["Cache-Control"])
assert cc.max_age == expected_max_age assert cc.max_age == expected_max_age
rv.close()
finally: finally:
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = max_age_default app.config["SEND_FILE_MAX_AGE_DEFAULT"] = max_age_default

View file

@ -32,45 +32,39 @@ class PyBytesIO:
class TestSendfile: class TestSendfile:
def test_send_file(self, app, req_ctx): def test_send_file(self, app, req_ctx):
rv = flask.send_file("static/index.html") with app.open_resource("static/index.html") as f:
expect = f.read()
with flask.send_file("static/index.html") as rv:
assert rv.direct_passthrough assert rv.direct_passthrough
assert rv.mimetype == "text/html" assert rv.mimetype == "text/html"
with app.open_resource("static/index.html") as f:
rv.direct_passthrough = False rv.direct_passthrough = False
assert rv.data == f.read() assert rv.data == expect
rv.close()
def test_static_file(self, app, req_ctx): def test_static_file(self, app, req_ctx):
# Default max_age is None. # Default max_age is None.
# Test with static file handler. # Test with static file handler.
rv = app.send_static_file("index.html") with app.send_static_file("index.html") as rv:
assert rv.cache_control.max_age is None assert rv.cache_control.max_age is None
rv.close()
# Test with direct use of send_file. # Test with direct use of send_file.
rv = flask.send_file("static/index.html") with flask.send_file("static/index.html") as rv:
assert rv.cache_control.max_age is None assert rv.cache_control.max_age is None
rv.close()
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 3600 app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 3600
# Test with static file handler. # Test with static file handler.
rv = app.send_static_file("index.html") with app.send_static_file("index.html") as rv:
assert rv.cache_control.max_age == 3600 assert rv.cache_control.max_age == 3600
rv.close()
# Test with direct use of send_file. # Test with direct use of send_file.
rv = flask.send_file("static/index.html") with flask.send_file("static/index.html") as rv:
assert rv.cache_control.max_age == 3600 assert rv.cache_control.max_age == 3600
rv.close()
# Test with pathlib.Path. # Test with pathlib.Path.
rv = app.send_static_file(FakePath("index.html")) with app.send_static_file(FakePath("index.html")) as rv:
assert rv.cache_control.max_age == 3600 assert rv.cache_control.max_age == 3600
rv.close()
class StaticFileApp(flask.Flask): class StaticFileApp(flask.Flask):
def get_send_file_max_age(self, filename): def get_send_file_max_age(self, filename):
@ -80,23 +74,21 @@ class TestSendfile:
with app.test_request_context(): with app.test_request_context():
# Test with static file handler. # Test with static file handler.
rv = app.send_static_file("index.html") with app.send_static_file("index.html") as rv:
assert rv.cache_control.max_age == 10 assert rv.cache_control.max_age == 10
rv.close()
# Test with direct use of send_file. # Test with direct use of send_file.
rv = flask.send_file("static/index.html") with flask.send_file("static/index.html") as rv:
assert rv.cache_control.max_age == 10 assert rv.cache_control.max_age == 10
rv.close()
def test_send_from_directory(self, app, req_ctx): def test_send_from_directory(self, app, req_ctx):
app.root_path = os.path.join( app.root_path = os.path.join(
os.path.dirname(__file__), "test_apps", "subdomaintestmodule" os.path.dirname(__file__), "test_apps", "subdomaintestmodule"
) )
rv = flask.send_from_directory("static", "hello.txt")
with flask.send_from_directory("static", "hello.txt") as rv:
rv.direct_passthrough = False rv.direct_passthrough = False
assert rv.data.strip() == b"Hello Subdomain" assert rv.data.strip() == b"Hello Subdomain"
rv.close()
class TestUrlFor: class TestUrlFor:
@ -319,15 +311,17 @@ class TestStreaming:
# response is closed without reading stream # response is closed without reading stream
client.get().close() client.get().close()
# response stream is read # response stream is read
assert client.get().text == "flask" with client.get() as rv:
assert rv.text == "flask"
# same as above, but with client context preservation # same as above, but with client context preservation
with client: with client:
client.get().close() client.get().close()
with client: with client, client.get() as rv:
assert client.get().text == "flask" assert rv.text == "flask"
class TestHelpers: class TestHelpers:

View file

@ -76,7 +76,6 @@ def test_client_open_environ(app, client, request):
return flask.request.remote_addr return flask.request.remote_addr
builder = EnvironBuilder(app, path="/index", method="GET") builder = EnvironBuilder(app, path="/index", method="GET")
request.addfinalizer(builder.close)
rv = client.open(builder) rv = client.open(builder)
assert rv.data == b"127.0.0.1" assert rv.data == b"127.0.0.1"