-
-
Notifications
You must be signed in to change notification settings - Fork 930
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Lazily build middleware stack #2017
Conversation
if self.middleware_stack is None: | ||
self.middleware_stack = self.build_middleware_stack() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that this will run when the lifespan is triggered most of the time but if the lifespan is disable it will run on the first request. So not checking scope["type"]
is by design.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've mentioned this in the description as well. 👍
starlette/applications.py
Outdated
@@ -140,16 +141,18 @@ def host( | |||
def add_middleware( | |||
self, middleware_class: type, **options: typing.Any | |||
) -> None: # pragma: no cover | |||
if self.middleware_stack is not None: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method is deprecated, isn't it @Kludex ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes it is. #2002 still came up last meeting as a problem.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not...
We didn't deprecate it. We only deprecated the decorators. 🤔
And "what came up on the meeting" was that I mentioned the add_middleware
as an issue, due to the linked GitHub issue above, and the idea was to deprecate, and further remove, but Adrian came up with this alternative solution.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Apologies, thank you for clarifying 😄
@@ -74,7 +74,7 @@ def __init__( | |||
{} if exception_handlers is None else dict(exception_handlers) | |||
) | |||
self.user_middleware = [] if middleware is None else list(middleware) | |||
self.middleware_stack = self.build_middleware_stack() | |||
self.middleware_stack: typing.Optional[ASGIApp] = None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO, build_middleware_stack
is a very cheap function to call. It makes sense to call it here and call once again when you need it again, and avoid nullable attributes (and null-checks).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moreover, it is not expected to be called often at runtime and affects only startup (configuration) time.
So calling it multiple times seems ok to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I’m not sure if you advocating for the old existing approach with the one being proposed here. Performance is not the goal, it is to avoid instantiating users middleware repeatedly as reported in #2002
starlette/applications.py
Outdated
@@ -140,16 +141,18 @@ def host( | |||
def add_middleware( | |||
self, middleware_class: type, **options: typing.Any | |||
) -> None: # pragma: no cover | |||
if self.middleware_stack is not None: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not...
We didn't deprecate it. We only deprecated the decorators. 🤔
And "what came up on the meeting" was that I mentioned the add_middleware
as an issue, due to the linked GitHub issue above, and the idea was to deprecate, and further remove, but Adrian came up with this alternative solution.
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
I think Flask 2.0 introduced a similar idea... I can't recall how was it. Someone knows? |
I think there's no need to add anything to the documentation regarding this change. This change shouldn't impact anyone - only if they are relying on the multiple instantiations' behavior, which I doubt. Maybe we can test this against FastAPI to see if there's something that may not be obvious that breaks over there - I can do it before the next release. 👍 |
self, middleware_class: type, **options: typing.Any | ||
) -> None: # pragma: no cover | ||
def add_middleware(self, middleware_class: type, **options: typing.Any) -> None: | ||
if self.middleware_stack is not None: # pragma: no cover |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can't we add a test for this case? 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like testing for the sake of testing but if you want I can add it.
@@ -486,3 +489,45 @@ async def startup(): | |||
|
|||
app.on_event("startup")(startup) | |||
assert len(record) == 1 | |||
|
|||
|
|||
def test_middleware_stack_init(test_client_factory: Callable[[ASGIApp], httpx.Client]): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would be the first time using httpx.Client
on this annotation on the test suite 👀
def test_middleware_stack_init(test_client_factory: Callable[[ASGIApp], httpx.Client]): | |
def test_middleware_stack_init(test_client_factory: Callable[[ASGIApp], TestClient]): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm yeah I should have used TestClient
. Luckily it's just a type annotation.
app = get_app() | ||
|
||
test_client_factory(app).get("/foo") | ||
|
||
assert SimpleInitializableMiddleware.counter == 2 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How relevant is this part for the test? 🤔
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
I don't know if that's what we're doing. But I did just notice this in one of our FastAPI projects and reduced it to the following example: # main.py
from fastapi import FastAPI
from fastapi.requests import *
from fastapi.responses import *
class MyHandledException(Exception):
"""My handled exception."""
async def my_exception_handler(
request: Request, exc: MyHandledException
) -> PlainTextResponse:
# fastapi==0.90.1 starlette==0.23.1 -> my_exception_handler executes
# fastapi==0.91.0 starlette==0.24.0 -> my_exception_handler does not execute
print(">>> Executing my_exception_handler <<<")
return PlainTextResponse(
f"Caught the following exception: {str(exc)}", status_code=400
)
app = FastAPI()
@app.on_event("startup")
async def on_startup() -> None:
print(">>> Executing on_startup <<<")
app.add_exception_handler(MyHandledException, my_exception_handler)
@app.get("/{thing}")
async def get_thing(thing):
if thing == "boom":
raise MyHandledException("Boom")
return f"Here you go: {thing}" In a Python 3.11 venv with After upgrading to When I register the handler like recommended in the FastAPI docs as below, then it does work @app.execution_handler(MyHandledException)
async def my_exception_handler(
... In our real application the exception handler comes from another package, so adding the But I've also read somewhere that |
Yes. It's the middleware stack. Note: I don't think we predicted users doing this @adriangb... 🤔 |
Oh jeez, we certainly did not predict this. Technically with the previous implementation you could add middleware at any time, even within a running request/response cycle, which I don't think should be allowed, period. Adding it within the lifespan seams a bit more reasonable: you could have a middleware that depends on something you initialize in the lifespan. |
if self.middleware_stack is None: | ||
self.middleware_stack = self.build_middleware_stack() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if self.middleware_stack is None: | |
self.middleware_stack = self.build_middleware_stack() | |
if scope["type"] != "lifespan" and self.middleware_stack is None: | |
self.middleware_stack = self.build_middleware_stack() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if the middleware runs something on lifespan? Maybe we need to build once before running the lifespan and then, if it's changed, we re-build it after running the lifespan?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually we want both scenarios...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you mean?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah :P
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you mean?
The same thing as your first message.
But actually... We don't want to rebuild, otherwise we introduce the issue that motivated this PR again.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I have a solution that allows dynamically adding middleware at any point in time and never re-building:
class ASGIAppProxy:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await self.app(scope, receive, send)
The reason we need to re-build in the first place is that the last user middleware needs to point to self.router/ExceptionMiddleware
. So say you have a single A
user middelware, the "stack" looks like ServerErrorMiddleware -> UserMiddlewareA -> ExceptionMiddleware -> Router
. Then you add a UserMiddlewareB
and it should be ServerErrorMiddleware -> UserMiddlewareA -> UserMiddlewareB -> ExceptionMiddleware -> Router
. So you need to change the ASGIApp that UserMiddlewareA
is pointing to from ExceptionMiddleware
to UserMiddlewareB
. We can't just assign a .app
attribute on the users middleware because it could be anything. By introducing the above wrapper we can do something like:
exc_middleware = ExceptionMiddleware(...)
tail = ASGIAppProxy(exc_middleware)
user_middleware_tail = UserMiddlewareA(tail)
# add UserMiddlewareB
new_tail = ASGIAppProxy(exc_middleware)
new_user_middleware_tail = UserMiddlewareB(new_tail)
tail.app = new_user_middleware_tail
tail = new_tail
Superseeds encode#2017 and fixes encode#2017 (comment)
For example? You can (and I guess you should) use the @Mark90 can you share what's the exact use-case that you have? |
We can also add an error on |
We already have one: https://github.com/encode/starlette/blob/master/starlette/applications.py#L139 |
An authentication middleware that needs a database connection or HTTP client.
I had not thought of that use case for scope[“state”], but it’s a good idea. Even without that feature of the middleware could just store whatever it needs on its instance. |
Why can’t you pass those to the constructor or call add_exception_handler before the lifespan? |
Ah, I only checked FastAPI's docs for exception handlers, not Starlette's. Didn't know about that constructor parameter, it works perfect. Thank you! |
Edit by @Kludex:
This is an alternative solution for deprecation + removal of the
add_middlware
. The solution here is to build the application with its middlewares only when we receive the first ASGI event - lifespan, websocket or http, doesn't matter.