Browse Source

Add SSEResponse class and sse_content helper for Server-Sent Events

Add native SSE support to FastAPI with:
- SSEResponse: A StreamingResponse subclass that automatically formats
  events according to the SSE specification with support for event types,
  data, IDs, and retry configuration
- sse_content: A helper function to create SSE-formatted content from a
  dictionary iterator

Both exports are added to the public FastAPI API.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
pull/14976/head
Santosh Bhavani 5 months ago
parent
commit
adb983b08b
  1. 2
      fastapi/__init__.py
  2. 137
      fastapi/responses.py
  3. 224
      tests/test_sse_response.py

2
fastapi/__init__.py

@ -20,6 +20,8 @@ from .param_functions import Query as Query
from .param_functions import Security as Security from .param_functions import Security as Security
from .requests import Request as Request from .requests import Request as Request
from .responses import Response as Response from .responses import Response as Response
from .responses import SSEResponse as SSEResponse
from .responses import sse_content as sse_content
from .routing import APIRouter as APIRouter from .routing import APIRouter as APIRouter
from .websockets import WebSocket as WebSocket from .websockets import WebSocket as WebSocket
from .websockets import WebSocketDisconnect as WebSocketDisconnect from .websockets import WebSocketDisconnect as WebSocketDisconnect

137
fastapi/responses.py

@ -1,4 +1,4 @@
from typing import Any from typing import Any, AsyncGenerator, Literal
from fastapi.exceptions import FastAPIDeprecationWarning from fastapi.exceptions import FastAPIDeprecationWarning
from starlette.responses import FileResponse as FileResponse # noqa from starlette.responses import FileResponse as FileResponse # noqa
@ -10,6 +10,141 @@ from starlette.responses import Response as Response # noqa
from starlette.responses import StreamingResponse as StreamingResponse # noqa from starlette.responses import StreamingResponse as StreamingResponse # noqa
from typing_extensions import deprecated from typing_extensions import deprecated
class SSEResponse(StreamingResponse):
"""Streaming response for Server-Sent Events (SSE).
This response class automatically formats events according to the SSE
specification and provides convenient methods for sending events.
Read more about Server-Sent Events in the
[FastAPI docs](https://fastapi.tiangolo.com/advanced/streaming-responses/#server-sent-events).
Example:
```python
from fastapi import FastAPI
from fastapi.responses import SSEResponse
app = FastAPI()
@app.get("/events")
def events():
def event_generator():
yield {"event": "message", "data": "Hello"}
yield {"event": "message", "data": "World"}
return SSEResponse(event_generator())
```
"""
default_media_type: str = "text/event-stream"
def __init__(
self,
content: AsyncGenerator[str, None] | AsyncGenerator[dict, None],
status_code: int = 200,
headers: dict[str, str] | None = None,
media_type: str | None = None,
background: Any = None,
retry: int | None = None,
) -> None:
self._retry = retry
super().__init__(
content=self._wrap_content(content),
status_code=status_code,
headers=headers,
media_type=media_type or self.default_media_type,
background=background,
)
async def _wrap_content(
self,
content: AsyncGenerator[str, None] | AsyncGenerator[dict, None],
) -> AsyncGenerator[bytes, None]:
"""Wrap the content to format SSE events."""
async for item in content:
if isinstance(item, dict):
yield self._format_event(item).encode("utf-8")
else:
yield item.encode("utf-8")
def _format_event(self, event_data: dict[str, Any]) -> str:
"""Format a dictionary as an SSE event."""
lines = []
if "event" in event_data:
lines.append(f"event: {event_data['event']}")
if "data" in event_data:
data = event_data["data"]
if isinstance(data, (list, dict)):
import json
data = json.dumps(data)
for line in str(data).split("\n"):
lines.append(f"data: {line}")
if "id" in event_data:
lines.append(f"id: {event_data['id']}")
if "retry" in event_data:
lines.append(f"retry: {event_data['retry']}")
elif self._retry is not None:
lines.append(f"retry: {self._retry}")
lines.append("")
return "\n".join(lines)
async def sse_content(
iterator: AsyncGenerator[dict[str, Any], None],
) -> AsyncGenerator[str, None]:
"""Helper to create SSE-formatted content from a dictionary iterator.
This is a convenience function that yields pre-formatted SSE strings,
useful when you want full control over the event formatting.
Example:
```python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse, sse_content
app = FastAPI()
@app.get("/events")
async def events():
async def generator():
yield {"event": "message", "data": "Hello"}
yield {"data": "World"}
return StreamingResponse(sse_content(generator()), media_type="text/event-stream")
```
"""
async for event in iterator:
lines = []
if "event" in event:
lines.append(f"event: {event['event']}")
if "data" in event:
data = event["data"]
if isinstance(data, (list, dict)):
import json
data = json.dumps(data)
for line in str(data).split("\n"):
lines.append(f"data: {line}")
if "id" in event:
lines.append(f"id: {event['id']}")
if "retry" in event:
lines.append(f"retry: {event['retry']}")
lines.append("")
yield "\n".join(lines)
try: try:
import ujson import ujson
except ImportError: # pragma: nocover except ImportError: # pragma: nocover

224
tests/test_sse_response.py

@ -0,0 +1,224 @@
"""Tests for Server-Sent Events (SSE) response."""
from collections.abc import AsyncGenerator
from typing import Any
import pytest
from fastapi import FastAPI
from fastapi.responses import SSEResponse, sse_content
from fastapi.testclient import TestClient
def test_sse_response_basic():
"""Test basic SSE response with event data."""
app = FastAPI()
@app.get("/events")
def events():
async def generator() -> AsyncGenerator[dict[str, Any], None]:
yield {"event": "message", "data": "Hello"}
yield {"data": "World"}
return SSEResponse(generator())
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/events")
assert response.status_code == 200
assert "text/event-stream" in response.headers["content-type"]
# Check that the SSE format is correct
content = response.text
assert "event: message" in content
assert "data: Hello" in content
assert "data: World" in content
def test_sse_response_with_retry():
"""Test SSE response with retry configuration."""
app = FastAPI()
@app.get("/events")
def events():
async def generator() -> AsyncGenerator[dict[str, Any], None]:
yield {"data": "Hello"}
return SSEResponse(generator(), retry=5000)
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/events")
assert response.status_code == 200
assert "retry: 5000" in response.text
def test_sse_response_with_event_id():
"""Test SSE response with event ID."""
app = FastAPI()
@app.get("/events")
def events():
async def generator() -> AsyncGenerator[dict[str, Any], None]:
yield {"event": "update", "data": "test", "id": "1"}
return SSEResponse(generator())
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/events")
assert response.status_code == 200
assert "event: update" in response.text
assert "data: test" in response.text
assert "id: 1" in response.text
def test_sse_response_string_content():
"""Test SSE response with pre-formatted string content."""
app = FastAPI()
@app.get("/events")
def events():
async def generator() -> AsyncGenerator[str, None]:
yield "data: Hello\n\n"
return SSEResponse(generator())
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/events")
assert response.status_code == 200
assert "data: Hello" in response.text
def test_sse_response_json_data():
"""Test SSE response with JSON data."""
app = FastAPI()
@app.get("/events")
def events():
async def generator() -> AsyncGenerator[dict[str, Any], None]:
yield {"data": {"message": "hello", "count": 42}}
return SSEResponse(generator())
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/events")
assert response.status_code == 200
# JSON data should be serialized
assert '"message": "hello"' in response.text
assert '"count": 42' in response.text
def test_sse_response_with_retry_in_event():
"""Test SSE response retry in event data overrides default."""
app = FastAPI()
@app.get("/events")
def events():
async def generator() -> AsyncGenerator[dict[str, Any], None]:
# Event-level retry should override constructor retry
yield {"data": "Hello", "retry": 3000}
return SSEResponse(generator(), retry=5000)
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/events")
assert response.status_code == 200
assert "retry: 3000" in response.text
# The default retry should not appear
lines = response.text.split("\n")
retry_count = sum(1 for line in lines if line.startswith("retry:"))
assert retry_count == 1
def test_sse_content_helper():
"""Test the sse_content helper function."""
app = FastAPI()
@app.get("/events")
def events():
async def generator() -> AsyncGenerator[dict[str, Any], None]:
yield {"event": "message", "data": "Hello"}
yield {"data": "World"}
from starlette.responses import StreamingResponse
return StreamingResponse(sse_content(generator()), media_type="text/event-stream")
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/events")
assert response.status_code == 200
assert "text/event-stream" in response.headers["content-type"]
content = response.text
assert "event: message" in content
assert "data: Hello" in content
assert "data: World" in content
def test_sse_response_custom_media_type():
"""Test SSE response with custom media type."""
app = FastAPI()
@app.get("/events")
def events():
async def generator() -> AsyncGenerator[dict[str, Any], None]:
yield {"data": "Hello"}
return SSEResponse(generator(), media_type="text/event-stream; charset=utf-8")
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/events")
assert response.status_code == 200
assert "text/event-stream" in response.headers["content-type"]
def test_sse_response_data_newlines():
"""Test SSE response with multiline data."""
app = FastAPI()
@app.get("/events")
def events():
async def generator() -> AsyncGenerator[dict[str, Any], None]:
yield {"data": "line1\nline2\nline3"}
return SSEResponse(generator())
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/events")
assert response.status_code == 200
# Each line should be prefixed with "data: "
assert "data: line1" in response.text
assert "data: line2" in response.text
assert "data: line3" in response.text
def test_sse_response_multiple_events():
"""Test SSE response with multiple events."""
app = FastAPI()
@app.get("/events")
def events():
async def generator() -> AsyncGenerator[dict[str, Any], None]:
yield {"event": "first", "data": "data1"}
yield {"event": "second", "data": "data2", "id": "2"}
yield {"data": "data3"}
return SSEResponse(generator())
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/events")
assert response.status_code == 200
content = response.text
# First event
assert "event: first" in content
assert "data: data1" in content
# Second event
assert "event: second" in content
assert "data: data2" in content
assert "id: 2" in content
# Third event (no event type)
assert content.count("data: data3") == 1
Loading…
Cancel
Save