Send ASGI lifespan events to api_transformer apps; drop the extra top-level Starlette app
All Submissions:
- Have you followed the guidelines stated in CONTRIBUTING.md file?
- Have you checked to ensure there aren't any other open Pull Requests for the desired changed?
Type of change
- Bug fix (non-breaking change which fixes an issue)
- This change requires a documentation update (included)
See "Behavior changes" below for the edges that could affect existing apps.
Changes To Core Features:
- Have you added an explanation of what your changes do and why you'd like us to include them?
- Have you written new tests for your core changes, as applicable?
- Have you successfully ran tests with your changes locally?
What and why
App.__call__ wrapped the whole backend in an extra Starlette(lifespan=...) and mounted the transformed app inside it. Starlette's Mount doesn't forward lifespan events, so the lifespan of anything passed to api_transformer never ran (a FastAPI lifespan=, on_startup handlers, a lifespan-aware middleware). That's the gap behind #5749. The extra app also added a router, a Mount and a second CORS middleware to every request (#5220).
This PR:
- Replaces the wrapper with
_RootMiddleware(reflex/app.py). It sets the Reflex contexts on every ASGI call, as_ContextMiddlewaredid, including during lifespan, and answers the server's lifespan events itself. All other scopes go straight to the app. - Sends real ASGI lifespan events to the transformer apps (
_run_asgi_lifespaninreflex/app_mixins/lifespan.py), the way a server does, so it works for any ASGI app, not only Starlette:- The outermost transformed app gets them. Middleware returned by a callable transformer forwards them inward per the ASGI spec.
- Starlette/FastAPI don't forward lifespan events to the apps they mount, and Reflex makes that
mount("", ...)itself. So the app each Starlette transformer mounts also gets them, except Reflex's ownStarletteapp, whose lifespan is the registered tasks. - Apps a user mounts on a transformer get nothing. Only the transformers and
register_lifespan_tasktasks run. - Every app shares the server's lifespan
state, so yielded state reachesrequest.state. - Like uvicorn's/granian's "auto" mode, an app that raises, returns or sends some other message instead of answering startup is skipped, with an info log.
startup.failedandshutdown.failedare raised, and lifespans already started still shut down.
- Order: Reflex lifespan tasks start first. Transformer apps start innermost first and stop in reverse, before the tasks stop.
- CORS: the top-level CORS layer is now added only when the outermost transformer is a callable, whose own routes would otherwise have none.
_apiand Starlette transformers keep their own CORS as before, so the header now appears exactly once. - Measured: about 11% less per-request ASGI overhead on
/ping, roughly 175 µs down to 155 µs in a direct ASGI loop.
Closes #5220. Fixes the problem in #5749.
Behavior changes reviewers should weigh
app()returns_RootMiddleware, notStarlette. It's typedASGIAppand already wasn't aStarlettewith OpenTelemetry on, and nothing in this repo relies on it. External code callingapp().mount(...)/url_path_for(...)would break; the performance news fragment calls this out.- Transformer startup code that never ran now runs: FastAPI
lifespan=,on_startup,@app.on_event("startup"). That's the fix, but a transformer whose startup raises will now fail server startup. - #5749 workaround runs twice. Users who also registered the transformer's lifespan with
register_lifespan_taskwould now run it twice.App.__call__warns when a Starlette transformer'srouter.lifespan_contextis also a registered task, and the news fragment tells users to drop the workaround. starlette_applifespan-task argument is now Reflex's ownStarletteapp instead of the internal wrapper. Code that searched the wrapper's routes for the transformer (the other workaround suggested in #5749) won't find it; the news fragment covers this.- Naive raw-ASGI middleware now sees the lifespan scope. Anything that reads
scope["path"]unconditionally raises on it. The server still starts; Reflex logs that the app appears not to support lifespan, and lifespans inside it don't run (they never did before). I fixed the docs' logging middleware example accordingly. - Error pages: an unhandled exception from a callable transformer's own app now gets the server's default 500 instead of the wrapper's
ServerErrorMiddleware. Reflex's own routes are unaffected. - Named-route lookup:
scope["router"]is now the outermost Starlette in the chain instead of the wrapper. Admin reverse routing is unchanged (tests cover it with and without a transformer), and it now also works behind a callable transformer, where the wrapper'sMountexposed no routes.
I also checked granian's and uvicorn's lifespan handling (granian passes state and treats a raise after startup.failed as fatal) and the OpenTelemetry ASGI middleware (passes non-HTTP scopes through). There's no overlap with #7416, which watches for the standard lifespan.startup.complete that _RootMiddleware still sends.
Tests
- Unit,
tests/units/test_app.py:- start/stop order across Reflex tasks, a FastAPI transformer, a forwarding callable and a Starlette transformer;
- state on
request.state; - mounted apps getting no events;
- CORS header exactly once whether the last transformer is Starlette or a callable;
- startup and shutdown failure reach the server;
- the double-registration warning.
- Unit,
tests/units/app_mixins/test_lifespan.py: a plain non-Starlette ASGI lifespan app, shared state, the three "unsupported" cases, and both failure modes. - Integration,
tests/integration/test_lifespan.py: the FastAPI transformer now has a lifespan whose start and stop are counted. Not run locally: the sandbox's ChromeDriver doesn't match its Chromium, so this PR's CI is the first run. I checked the same flow under a real uvicorn server instead. - Checks run locally:
uv run pre-commit runon the changed files: ruff, codespell, pyright, ty, pyi all pass.- The
docs/appdoc-link tests pass. - The full
tests/unitsrun has 262 failures, all inreflex_cli/v2and thereflex_benchbrowser driver; they fail the same way on unmodifiedmainin this sandbox (no network/browser).
Generated by Claude Code