Skip to content
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

Merged
merged 11 commits into from
Feb 6, 2023
Merged

Conversation

adriangb
Copy link
Member

@adriangb adriangb commented Jan 23, 2023

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.

Comment on lines +124 to +125
if self.middleware_stack is None:
self.middleware_stack = self.build_middleware_stack()
Copy link
Member Author

@adriangb adriangb Jan 23, 2023

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.

Copy link
Member

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. 👍

@adriangb adriangb requested review from Kludex and a team January 23, 2023 18:09
@@ -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:
Copy link
Member

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 ?

Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member Author

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
Copy link
Member

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).

Copy link
Member

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.

Copy link
Member Author

@adriangb adriangb Jan 30, 2023

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 Show resolved Hide resolved
@@ -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:
Copy link
Member

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>
starlette/applications.py Outdated Show resolved Hide resolved
@Kludex
Copy link
Member

Kludex commented Feb 4, 2023

I think Flask 2.0 introduced a similar idea... I can't recall how was it. Someone knows?

@Kludex
Copy link
Member

Kludex commented Feb 4, 2023

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
Copy link
Member

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? 🤔

Copy link
Member Author

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.

@adriangb adriangb enabled auto-merge (squash) February 6, 2023 05:34
@@ -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]):
Copy link
Member

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 👀

Suggested change
def test_middleware_stack_init(test_client_factory: Callable[[ASGIApp], httpx.Client]):
def test_middleware_stack_init(test_client_factory: Callable[[ASGIApp], TestClient]):

Copy link
Member Author

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.

@adriangb adriangb merged commit 51c1de1 into encode:master Feb 6, 2023
@adriangb adriangb deleted the fix-add-middleware branch February 6, 2023 05:35
Comment on lines +529 to +533
app = get_app()

test_client_factory(app).get("/foo")

assert SimpleInitializableMiddleware.counter == 2
Copy link
Member

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? 🤔

@Mark90
Copy link

Mark90 commented Mar 29, 2023

@Kludex

This change shouldn't impact anyone - only if they are relying on the multiple instantiations' behavior, which I doubt.

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 uvicorn==0.21.1 fastapi==0.90.1 starlette==0.23.1, started the app with uvicorn main:app, called the endpoint with "/boom" -> MyHandledException is picked up by the exception handler just fine.

After upgrading to fastapi==0.91.0 starlette=0.24.0 -> the on_startup is executed, but MyHandledException is not picked up by the handler. It doesn't seem to be in the middleware stack, I'm not completely sure why. Perhaps the stack is already initialized at this point?

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 @app.exception_handler decorator is not an option.

But I've also read somewhere that on_startup event is going to be replaced by "Lifespan", I'll have to read up on that (to see if it can be used for dynamically registering exception handlers)

@Kludex
Copy link
Member

Kludex commented Mar 29, 2023

Perhaps the stack is already initialized at this point?

Yes. It's the middleware stack.

Note: I don't think we predicted users doing this @adriangb... 🤔

@adriangb
Copy link
Member Author

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.
The good news is that we can probably move building the stack until after the ASGI lifespan has been run, which would solve the example above.

Comment on lines +116 to +117
if self.middleware_stack is None:
self.middleware_stack = self.build_middleware_stack()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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()

Copy link
Member Author

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?

Copy link
Member

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...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah :P

Copy link
Member

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.

Copy link
Member Author

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

adriangb added a commit to adriangb/starlette that referenced this pull request Mar 30, 2023
@Kludex
Copy link
Member

Kludex commented Mar 30, 2023

...you could have a middleware that depends on something you initialize in the lifespan.

For example? You can (and I guess you should) use the scope["state"] in that middleware.

@Mark90 can you share what's the exact use-case that you have?

@Kludex
Copy link
Member

Kludex commented Mar 30, 2023

We can also add an error on add_middleware in case it's used after the middleware stack is built.

@adriangb
Copy link
Member Author

We can also add an error on add_middleware in case it's used after the middleware stack is built.

We already have one: https://github.com/encode/starlette/blob/master/starlette/applications.py#L139

@adriangb
Copy link
Member Author

adriangb commented Mar 30, 2023

For example?

An authentication middleware that needs a database connection or HTTP client.

You can (and I guess you should) use the scope["state"] in that middleware.

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.

@Mark90
Copy link

Mark90 commented Mar 30, 2023

@Kludex

@Mark90 can you share what's the exact use-case that you have?

We have a library containing endpoints / exceptions (and handlers) that we reuse among a few projects. Using the shared exception handler in those projects requires .add_exception_handler() as far as I know.

@adriangb
Copy link
Member Author

Why can’t you pass those to the constructor or call add_exception_handler before the lifespan?

@Mark90
Copy link

Mark90 commented Mar 30, 2023

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Middleware stacking and recursive initialization
4 participants