Returning an EventSourceResponse from a path operation sends an empty 200 body instead of the stream #16107
Replies: 6 comments
|
Good catch and solid repro. The root cause is clear: When The The fix should be in the routing layer when the return value is Since you already have a fix and tests ready, go ahead and open |
|
Thanks for looking at this. One correction on the mechanism, since it changes what the fix can be, the return annotation isn't involved and the branch is taken before the endpoint runs: # fastapi/routing.py
if is_sse_stream: # from lenient_issubclass(actual_response_class, EventSourceResponse)
gen = dependant.call(**solved_result.values) # endpoint called inside the branchSo there is no response object to That also means it is not limited to endpoints that return a Response but any non-generator with from fastapi import FastAPI
from fastapi.responses import EventSourceResponse
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/async-dict", response_class=EventSourceResponse)
async def async_dict():
return {"msg": "hello"}
@app.get("/sync-dict", response_class=EventSourceResponse)
def sync_dict():
return {"msg": "hello"}
client = TestClient(app, raise_server_exceptions=False)
for path in ("/async-dict", "/sync-dict"):
r = client.get(path)
print(path, r.status_code, repr(r.text))On 0.141.0: The sync one is one that worries me — no exception, no warning, just wrong data on the wire. The route object already computes this correctly, generator check included: # fastapi/routing.py, in get_api_route / route setup
route.is_sse_stream = is_generator and lenient_issubclass(
response_class, EventSourceResponse
)
route.is_json_stream = is_generator and isinstance(response_class, DefaultPlaceholder)It's just never handed to the request handler. At the stream_item_field=route.stream_item_field,
is_json_stream=route.is_json_stream,
# is_sse_stream=route.is_sse_stream <-- missing
)So |
This comment was marked as spam.
This comment was marked as spam.
|
This occurs due to double-wrapping of the Root CauseWhen you declare If your endpoint returns an FixYou have two clean ways to fix this depending on your preferred pattern: Pattern 1: Return the generator directly when using @app.get("/with-response-class", response_class=EventSourceResponse)
async def with_rc():
async for item in gen():
yield itemPattern 2: Omit @app.get("/no-response-class")
async def no_rc() -> EventSourceResponse:
return EventSourceResponse(gen()) |
|
Thanks — both of those do work, and they're what I'd tell someone to do today to get unstuck. I want to flag though that they're avoidance rather than resolution: each one sidesteps the broken combination by not using it. Dropping The part I'd still like a maintainer's read on is that the combination fails silently. An async endpoint returns 200 with an empty body, and a sync one streams the dict's keys as events with no error at all: If the intended answer is "these two are simply not meant to be combined", then the fix is arguably a startup-time error rather than a silent 200 — but right now nothing tells the user either way. I have a patch and tests ready either way, happy to open a PR if that's the preferred direction. |
Uh oh!
There was an error while loading. Please reload this page.
First Check
Commit to Help
Example Code
Description
So ran a example: a path operation with response_class=EventSourceResponse that gives an EventSourceResponse directly. Its returning 200 with content-type text/event-stream but the body is empty ('').
I was expecting the body to be
data: hello\n\n. Returning a response directly is normal way to override the response in FastAPI, so I was expecting that to work here too.The same code works and returns the stream without response_class on the decorator (/no-response-class in the example), so returning the response directly is supported. It only breaks when response_class=EventSourceResponse is declared.
Also under uvicorn it is worse than empty body: the connection truncates and client gets
RemoteProtocolError: peer closed connection without sending complete message bodyerror.The server logs
TypeError: 'coroutine' object is not iterableerror. Status and headers are already sent out, so no exception handling runs here. The endpoint coroutine is never awaited.It seems the handler takes the SSE branch from the response class only, without checking whether the endpoint is really a generator.
I have a fix and tests ready and i am happy to open a PR if this is the right direction.
Operating System
Linux
Operating System Details
Ubuntu 24.04.4 LTS (kernel 6.8.0-84)
FastAPI Version
0.141.0
Pydantic Version
2.13.4
Python Version
3.12.3
Additional Context
Verified on installs from PyPI of fastapi 0.135.0 (the first release with SSE, per the release notes) and 0.141.0. Same results in both: 200, empty body, coroutine was never awaited. So this looks like it has been there since SSE was added.
Related but not the same: #15441 reports missing keep-alive pings when returning EventSourceResponse without response_class on the decorator. In that case stream does work. Here its withresponse_class=EventSourceResponse, the body is empty.
Traceback under uvicorn
The client gets:
All reactions