fix: FastAPI TestClient compatibility and lifespan re-initialization#3736
fix: FastAPI TestClient compatibility and lifespan re-initialization#3736kvdhanush06 wants to merge 1 commit intoPrefectHQ:mainfrom
Conversation
Test Failure AnalysisSummary: The static analysis workflow failed because Root Cause:
Suggested Solution: Run uv run ruff format tests/server/test_fastapi_testclient_compat.py
# then commit the changesOr run the full prek suite (as required by CLAUDE.md): uv run prek run --all-filesDetailed AnalysisThe workflow run Only two files were changed in this PR:
In the test file diff, there's trailing whitespace visible on:
And there is no blank line between the two top-level test functions, which ruff format requires. Related Files
|
There was a problem hiding this comment.
Pull request overview
This PR fixes a RuntimeError when using FastAPI’s TestClient multiple times against a mounted FastMCP HTTP app by making the underlying StreamableHTTPSessionManager effectively “lifespan-reusable” via a wrapper that recreates the manager per lifespan entry.
Changes:
- Added
ReusableSessionManagerWrapperto lazily create a freshStreamableHTTPSessionManagerfor each ASGI lifespan cycle. - Updated
create_streamable_http_app()to use a session-manager factory + wrapper rather than a single long-lived manager instance. - Added regression tests covering multiple sequential
TestClientlifespan runs for a mounted FastMCP app.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/fastmcp/server/http.py |
Wraps the streamable HTTP session manager so the app’s lifespan can be entered multiple times (e.g., repeated TestClient usage). |
tests/server/test_fastapi_testclient_compat.py |
Adds regression tests intended to validate repeated FastAPI TestClient lifespan cycles don’t raise the prior RuntimeError. |
jlowin
left a comment
There was a problem hiding this comment.
Thanks for tackling this — the bug is real and the diagnosis is correct. StreamableHTTPSessionManager.run() can only be called once, so re-entering the lifespan (as TestClient does) fails on the second cycle.
The fix can be much simpler though. The root cause is that the session manager is created outside the lifespan but run inside it. Since the lifespan is already a closure inside create_streamable_http_app, it has access to all the construction parameters. Just move the creation into the lifespan:
# Create the ASGI app wrapper (session manager is set each lifespan cycle)
streamable_http_app = StreamableHTTPASGIApp(None)
@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
streamable_http_app.session_manager = StreamableHTTPSessionManager(
app=server._mcp_server,
event_store=event_store,
retry_interval=retry_interval,
json_response=json_response,
stateless=stateless_http,
)
async with server._lifespan_manager(), streamable_http_app.session_manager.run():
yieldNo wrapper class, no factory — just create a fresh manager at the start of each lifespan cycle and assign it to the ASGI app. ASGI guarantees the lifespan runs before any requests, so the initial None is never hit by a request handler (and StreamableHTTPASGIApp.__call__ already handles that error case with a helpful message).
The ReusableSessionManagerWrapper can be removed entirely.
The tests are good as a regression suite, but the status code assertion in [200, 405, 404, 406] doesn't verify anything meaningful — the test's real assertion is just "no RuntimeError." Simplify to:
with TestClient(app) as client:
client.get("/analytics/mcp") # Would raise RuntimeError before fix830f9e4 to
fe0da26
Compare
fe0da26 to
8ce795d
Compare
Description
Closes #2375
FastMCPraised aRuntimeErrorwhen using FastAPI'sTestClientacross multiple test runs. The root cause wasStreamableHTTPSessionManagerenforcing a strict single-execution constraint on its lifespan. SinceTestClienttriggers the full application lifespan on every context entry, each subsequentwith TestClient(app)block attempted to re-enter an already-consumed manager, causing the failure.Root Cause
StreamableHTTPSessionManageris designed to be run once. Re-entering its lifespan raises aRuntimeError. BecauseTestClientspins up and tears down the ASGI lifespan on everywithblock, any test suite with more than oneTestClientcontext would fail on the second invocation.Fix
Introduced
ReusableSessionManagerWrapperinsrc/fastmcp/server/http.py. The wrapper accepts a factory callable instead of a manager instance directly. Each time the lifespan is entered, it lazily constructs a freshStreamableHTTPSessionManagervia the factory, ensuring every test run — and every lifespan cycle in general — operates on a correctly initialized instance.No changes to the public API or existing behavior for single-run scenarios.
Verification
Added a regression suite in
tests/server/test_fastapi_testclient_compat.pycovering:TestClientcontext entriesContribution type
Checklist
uv run prek run --all-filesand all checks pass