From 0f3e7bd682a81488919227f2b5f1f7de1718ecdd Mon Sep 17 00:00:00 2001 From: Zawwar Sami <105767627+Zawwarsami16@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:54:54 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20line=20splitting=20in=20`f?= =?UTF-8?q?ormat=5Fsse=5Fevent`=20to=20comply=20with=20SSE=20spec=20(#1551?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Yurii Motov <109919500+YuriiMotov@users.noreply.github.com> Co-authored-by: Sebastián Ramírez --- fastapi/sse.py | 10 ++++++++-- tests/test_sse.py | 25 ++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/fastapi/sse.py b/fastapi/sse.py index 1e2bd86171..642ff77340 100644 --- a/fastapi/sse.py +++ b/fastapi/sse.py @@ -156,6 +156,12 @@ class ServerSentEvent(BaseModel): return self +def _split_sse_lines(value: str) -> list[str]: + # Split on SSE-spec line terminators only (\n, \r\n, \r), preserving + # trailing empty strings. + return value.replace("\r\n", "\n").replace("\r", "\n").split("\n") + + def format_sse_event( *, data_str: Annotated[ @@ -206,14 +212,14 @@ def format_sse_event( lines: list[str] = [] if comment is not None: - for line in comment.splitlines(): + for line in _split_sse_lines(comment): lines.append(f": {line}") if event is not None: lines.append(f"event: {event}") if data_str is not None: - for line in data_str.splitlines(): + for line in _split_sse_lines(data_str): lines.append(f"data: {line}") if id is not None: diff --git a/tests/test_sse.py b/tests/test_sse.py index fdc8561428..e466cc0f76 100644 --- a/tests/test_sse.py +++ b/tests/test_sse.py @@ -6,7 +6,7 @@ import fastapi.routing import pytest from fastapi import APIRouter, FastAPI from fastapi.responses import EventSourceResponse -from fastapi.sse import ServerSentEvent +from fastapi.sse import ServerSentEvent, format_sse_event from fastapi.testclient import TestClient from pydantic import BaseModel @@ -373,6 +373,29 @@ def test_no_keepalive_when_fast(client: TestClient): assert ": ping\n" not in response.text +@pytest.mark.parametrize( + ("data", "expected_result"), + [ + ("Hello\n", b"data: Hello\ndata: \n\n"), + ("Hello\n\n", b"data: Hello\ndata: \ndata: \n\n"), + ("\n", b"data: \ndata: \n\n"), + ("Hello\r\nWorld", b"data: Hello\ndata: World\n\n"), + ("Hello\rWorld", b"data: Hello\ndata: World\n\n"), + ("A\u2028B", "data: A\u2028B\n\n".encode()), + ("A\vB", b"data: A\x0bB\n\n"), + ("", b"data: \n\n"), + ], +) +def test_format_sse_event_splitlines_behavior_in_data( + data: str, expected_result: bytes +) -> None: + assert format_sse_event(data_str=data) == expected_result + + +def test_format_sse_event_splitlines_behavior_in_comment(): + assert format_sse_event(comment="hi\n") == b": hi\n: \n\n" + + # default_response_class tests