encode/starlette

[Document] Order of middlewares

Closed this issue · 7 comments

I just started a project with starlette awesome, and found a small issue about the docs related on the order of execution of middlewares... My case, is so usual, like in django:

auth_middleware that depends on session_middleware

Seems like the order is inverse on the order you declare them:

app.add_middleware(AuthenticationMiddleware, backend=middlewares.AuthBackend())
app.add_middleware(middlewares.SessionMiddleware)

The session one is executed first but needs to be added later. I'm not against it, it's an onion, and can be seen in both directions... But I thought that at least should be documented.

On the API side, perhaps is a nice idea, to just instantiate them on a single call, like:

app.add_middlewares([
   middlewareX, middlewareY  # in order of execution... (at least when the request is entering..)
])

Yeah, I'm not a fan of how it is at the moment, but it's a bit unavoidable with the current style. (We can only wrap middleware around what's already been added.)

And yes, we'll eventually do something more like this...

middleware = [
    Middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS),
    Middleware(HTTPSRedirectMiddleware, enabled=not DEBUG),
    Middleware(SessionMiddleware, backend=CookieSignedSessions(secret_key=SECRET_KEY)),
    Middleware(AuthMiddleware, backend=DatabaseAuthBackend(model=User)),
]

app = Starlette(
    debug=DEBUG,
    routes=routes,
    events=events,
    middleware=middleware,
    exception_handlers=exception_handlers
)

See #396

Something like this?

class App(Starlette):
    async def __call__(self, scope, receive, send):
        scope['app'] = self
        # middlewares could be a list
        # and also some kind of predicates could be added 
        # to just at runtime, change the middlware chain.
        # or the result, could just be cached.
        result = compose(
            self.router,
            (TransactionMiddleware, ),
            (ExceptionMiddleware, dict(debug=self.debug)),
            (ServerErrorMiddleware, dict(debug=self.debug))
        )
        await result(scope, receive, send)


def compose(initial, *args):
    base = initial
    for arg in args:
        if len(arg) == 2:
            klass, kwargs = arg
            base = klass(base, **kwargs)
        elif len(arg) == 1:
            klass = arg[0]
            base = klass(base)
    return base


app = App(debug=True)

had the same issue, just adding some documentation would be great as this is a pretty opaque bug to fix.

Thank you so much @jordic !! Should definitely be in the doc imo.

I think this was achieved in Version 0.13.0. From the current docs:

Every Starlette application automatically includes two pieces of middleware
by default:

  • ServerErrorMiddleware - Ensures that application exceptions may return a custom 500 page, or display an application traceback in DEBUG mode. This is always the outermost middleware layer.
  • ExceptionMiddleware - Adds exception handlers, so that particular types of expected exception cases can be associated with handler functions. For example raising HTTPException(status_code=404) within an endpoint will end up rendering a custom 404 page.

Middleware is evaluated from top-to-bottom, so the flow of execution in our example
application would look like this:

  • Middleware
    • ServerErrorMiddleware
    • TrustedHostMiddleware
    • HTTPSRedirectMiddleware
    • ExceptionMiddleware
  • Routing
  • Endpoint

So this can be closed now.

For anyone landing here trying to understand Middleware execution order, I did some testing with 3 custom middleware.

class Middleware:
    def __init__(self, app, name) -> None:
        self.app = app
        self.name = str(name)

    async def __call__(self, scope, receive, send) -> None:
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return

        async def new_receive():
            print("Receive: " + self.name)
            return await receive()

        async def new_send(message) -> None:
            print("Send: " + self.name)
            return await send(message)

        try:
            print("Before: " + self.name)
            await self.app(scope, new_receive, new_send)
            print("After: " + self.name)
        except:
            print("Exception: " + self.name)
            raise

They were added in this order

app.add_middleware(Middleware, name=1)
app.add_middleware(Middleware, name=2)
app.add_middleware(Middleware, name=3)

And here were the execution results

Before: 3
Before: 2
Before: 1

Receive: 1
Receive: 2
Receive: 3

Send: 1
Send: 2
Send: 3

After: 1
After: 3
After: 3

You can see they execute in the expected order for everything except for code ran prior to awaiting the app.

If an Exception is raised in the route, you lose all theAfter code and instead get

Exception: 1
Exception: 2
Exception: 3

Also keep in mind, if a middleware suppresses an error by not re-raising it, you get something like

Exception: 1
After: 2
After: 3

Hope this helps someone.

@zpyoung Specifically in my case I've manage to resolve the error AssertionError: SessionMiddleware must be installed to access request.session following your understanding: A LIFO ordering, first the custom middleware, then their dependancies/"native" middleware.

# Custom:
app.add_middleware(TenantMiddleware)
# Custom:
app.add_middleware(AppUserAuthMiddleware)
# "Native" & basis for `AppUserAuthMiddleware` 
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET)
# "Native"
app.add_middleware(CORSMiddleware, **CORS_SETTINGS)

Thank you!