flask icon indicating copy to clipboard operation
flask copied to clipboard

3.1.2 regression: `stream_with_context` triggers `teardown_request()` calls before response generation

Open noirbee opened this issue 4 months ago • 6 comments

Hello,

I believe the changes to stream_with_context() in https://github.com/pallets/flask/pull/5799/commits/9822a0351574790cb66c652fcc396ad7aa2b09d8 introduced a bug where the teardown_request() callables are invoked too early in the request/response lifecycle (and actually invoked twice, before generating the response and a second time after the end of the request). Take the following example:

# flask_teardown_stream_with_context.py
from flask import Flask, g, stream_with_context


def _teardown_request(_):
    print("do_teardown_request() called")
    g.pop("hello")


app = Flask(__name__)

app.teardown_request(_teardown_request)


@app.get("/stream")
def streamed_response():
    g.hello = "world"

    def generate():
        print("Starting to generate response")
        yield f"<p>Hello {g.hello} !</p>"

    return stream_with_context(generate())


app.run(debug=True)

In 3.1.1:

% /tmp/venv/bin/flask --version           
Python 3.13.7
Flask 3.1.1
Werkzeug 3.1.3
% /tmp/venv/bin/python flask_teardown_stream_with_context.py 
[…]
Starting to generate response
127.0.0.1 - - [01/Sep/2025 16:07:05] "GET /stream HTTP/1.1" 200 -
do_teardown_request() called

In 3.1.2:

% /tmp/venv/bin/flask --version                             
Python 3.13.7
Flask 3.1.2
Werkzeug 3.1.3
% /tmp/venv/bin/python flask_teardown_stream_with_context.py
do_teardown_request() called
Starting to generate response
do_teardown_request() called
Debugging middleware caught exception in streamed response at a point where response headers were already sent.
Traceback (most recent call last):
  File "/tmp/venv/lib/python3.13/site-packages/flask/helpers.py", line 132, in generator
    yield from gen
  File "/tmp/flask_teardown_stream_with_context.py", line 21, in generate
    yield f"<p>Hello {g.hello} !</p>"
                      ^^^^^^^
  File "/tmp/venv/lib/python3.13/site-packages/flask/ctx.py", line 56, in __getattr__
    raise AttributeError(name) from None
AttributeError: hello

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/tmp/venv/lib/python3.13/site-packages/werkzeug/wsgi.py", line 256, in __next__
    return self._next()
           ~~~~~~~~~~^^
  File "/tmp/venv/lib/python3.13/site-packages/werkzeug/wrappers/response.py", line 32, in _iter_encoded
    for item in iterable:
                ^^^^^^^^
  File "/tmp/venv/lib/python3.13/site-packages/flask/helpers.py", line 130, in generator
    with app_ctx, req_ctx:
                  ^^^^^^^
  File "/tmp/venv/lib/python3.13/site-packages/flask/ctx.py", line 443, in __exit__
    self.pop(exc_value)
    ~~~~~~~~^^^^^^^^^^^
  File "/tmp/venv/lib/python3.13/site-packages/flask/ctx.py", line 410, in pop
    self.app.do_teardown_request(exc)
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^
  File "/tmp/venv/lib/python3.13/site-packages/flask/app.py", line 1356, in do_teardown_request
    self.ensure_sync(func)(exc)
    ~~~~~~~~~~~~~~~~~~~~~~^^^^^
  File "/tmp/flask_teardown_stream_with_context.py", line 7, in _teardown_request
    g.pop("hello")
    ~~~~~^^^^^^^^^
  File "/tmp/venv/lib/python3.13/site-packages/flask/ctx.py", line 88, in pop
    return self.__dict__.pop(name)
           ~~~~~~~~~~~~~~~~~^^^^^^
KeyError: 'hello'
127.0.0.1 - - [01/Sep/2025 16:09:35] "GET /stream HTTP/1.1" 200 -

Specifically,

do_teardown_request() called
Starting to generate response
do_teardown_request() called

So _teardown_request() is called before flask start to iterate on the response generator.

This is a simplified version of our own code; I'm not sure we can actually expect g to still be available during response generators, but given it worked in 3.1.1 and the phrasing / intent of teardown_request(), I'd expect it not to be called before the response is actually generated. Note also that removing the code which causes the error, i.e. the g access, and keeping just the print() for debugging, will still show _teardown_request() being called twice.

It's not obvious to me where exactly the bug is triggered. Adding a traceback.print_stack() call to _teardown_request():

  • in 3.1.1, the only call, once the request is done, is triggered by https://github.com/pallets/flask/blob/7fff56f5172c48b6f3aedf17ee14ef5c2533dfd1/src/flask/helpers.py#L115 ⇒ https://github.com/pallets/flask/blob/330123258e8c3dc391cbe55ab1ed94891ca83af3/src/flask/ctx.py#L443
  • in 3.1.2, the (new) first call before entering the response generator is triggered by https://github.com/pallets/flask/blob/330123258e8c3dc391cbe55ab1ed94891ca83af3/src/flask/app.py#L1527 ; the second is similar to 3.1.1, i.e. https://github.com/pallets/flask/blob/330123258e8c3dc391cbe55ab1ed94891ca83af3/src/flask/helpers.py#L130 ⇒ https://github.com/pallets/flask/blob/330123258e8c3dc391cbe55ab1ed94891ca83af3/src/flask/ctx.py#L443

Environment:

  • Python version: 3.13
  • Flask version: 3.1.2

noirbee avatar Sep 01 '25 14:09 noirbee

This will be fixed as a side effect of #5812 in 3.2. I'm not sure how to fix it in the mean time, without reintroducing the other issue, but I'm open to reviewing a PR if you can figure it out sooner.

I do understand how this is an issue, but note that the docs already call out that you can't make assumptions about how many times teardown functions will run or what will have or have not run before them. That's something you should address regardless of this being fixed.

davidism avatar Sep 13 '25 18:09 davidism

This will be fixed as a side effect of https://github.com/pallets/flask/pull/5812 in 3.2. I'm not sure how to fix it in the mean time

Seems like it does the trick indeed, thanks. The fact that it won't be fixed until then is not really an issue for us, I've just pinned flask to <3.1.2 until we can upgrade.

the docs already call out that you can't make assumptions about how many times teardown functions will run or what will have or have not run before them

https://flask.palletsprojects.com/en/stable/reqcontext/#teardown-callbacks here ? It's not immediately obvious to me that they could run more than once, but even then this isn't the original issue we encountered, because our teardown callbacks are indeed idempotent — the g.pop() was used here to exhibit the issue.

My issue was more around when it can be called: in this specific case, even before entering the generator passed to stream_with_context(). I wouldn't expect a teardown callback to be called at this point, considering do_teardown_request() is « Called after the request is dispatched and the response is returned ».

noirbee avatar Sep 15 '25 14:09 noirbee

Is there an expected release date for 3.2.0? We also encountered this problem in OTEL.

qiqizjl avatar Sep 23 '25 02:09 qiqizjl

Hi I tested the code of @noirbee and indeed it does give the same error (venv-flask) ➜ flask git:(fix-streaming-teardown) ✗ flask --version Python 3.13.5 Flask 3.2.0.dev0 Werkzeug 3.1.3

  • Running on http://127.0.0.1:5000 Press CTRL+C to quit
  • Restarting with stat
  • Debugger is active!
  • Debugger PIN: 140-638-840 Starting to generate response 127.0.0.1 - - [29/Sep/2025 23:09:47] "GET /stream HTTP/1.1" 200 - do_teardown_request() called do_teardown_request() called Debugging middleware caught exception in streamed response at a point where response headers were already sent. Traceback (most recent call last): File "/Users/anand/Desktop/oss/flask/src/flask/app.py", line 1500, in call return self.wsgi_app(environ, start_response) ~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/anand/Desktop/oss/flask/src/flask/app.py", line 1491, in wsgi_app ctx.pop(error)

File "/Users/anand/Desktop/oss/flask/src/flask/ctx.py", line 474, in pop self.app.do_teardown_request(exc) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^ File "/Users/anand/Desktop/oss/flask/src/flask/app.py", line 1339, in do_teardown_request self.ensure_sync(func)(exc) ~~~~~~~~~~~~~~~~~~~~~~^^^^^ File "/Users/anand/Desktop/oss/flask/1.py", line 7, in _teardown_request g.pop("hello") ~~~~~^^^^^^^^^ File "/Users/anand/Desktop/oss/flask/src/flask/ctx.py", line 88, in pop return self.dict.pop(name) ~~~~~~~~~~~~~~~~~^^^^^^ KeyError: 'hello'

But: when I add decorator for streaming with context it works correctly

from flask import Flask, g, stream_with_context

def teardown_request(): print("do_teardown_request() called") g.pop("hello")

app = Flask(name)

app.teardown_request(_teardown_request)

@app.get("/stream") def streamed_response(): g.hello = "world"

@stream_with_context # change here
def generate():
    print("Starting to generate response")
    yield f"<p>Hello {g.hello} !</p>"

return stream_with_context(generate())

app.run(debug=True)

Even Replacing g.pop("hello") with g.pop("hello", None) works suggesting its being trying to popped multiple times and cant find hence error

relu101 avatar Sep 29 '25 17:09 relu101

I think we should supress the error as a warning till the issue gets resolves as a side effect by wrapping the teardown call on the lines of:

from contextlib import suppress

def do_teardown_request(self, exc: BaseException | None = None) -> None: """Called after the request is dispatched and the response is finalized.""" req = _cv_app.get().request

for name in chain(req.blueprints, (None,)):
    if name in self.teardown_request_funcs:
        for func in reversed(self.teardown_request_funcs[name]):
            with suppress(KeyError):
                self.ensure_sync(func)(exc)

request_tearing_down.send(self, _async_wrapper=self.ensure_sync, exc=exc)

relu101 avatar Sep 29 '25 18:09 relu101

sorry I am new to open source contribution so please forgive if formatting issues,

relu101 avatar Sep 29 '25 18:09 relu101

We are suffering the same issue with 3.1.2, and had to pin Flask to version < 3.1.2. Did not quite get it: is this expected to be resolved in Flask 3.2.0? Thanks

watttman avatar Dec 17 '25 15:12 watttman

is this expected to be resolved in Flask 3.2.0?

yes

davidism avatar Dec 17 '25 15:12 davidism