fastapi - 💡(How to fix) Fix SSE `stream_item_type` not propagated through `APIRouter` + `include_router` [1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
fastapi/fastapi#15401Fetched 2026-04-22 07:42:59
View on GitHub
Comments
0
Participants
1
Timeline
1
Reactions
0
Author
Participants
Timeline (top)
connected ×1

When an SSE route (response_class=EventSourceResponse + AsyncIterator[Frame] return annotation) is defined on an APIRouter and then merged onto a FastAPI app via include_router, the resulting merged route loses its stream_item_type. As a consequence, the OpenAPI text/event-stream response schema omits the data.contentSchema, so tooling like datamodel-codegen cannot generate frame models.

Defining the same route directly on the FastAPI app via @app.post(...) works correctly.

Root Cause

Root cause (proposed)

Code Example

from collections.abc import AsyncIterator

from fastapi import APIRouter, FastAPI
from fastapi.sse import EventSourceResponse
from pydantic import BaseModel


class Frame(BaseModel):
    kind: str


# Case A — route registered directly on the app: works
app_a = FastAPI()

@app_a.post("/s", response_class=EventSourceResponse)
async def a() -> AsyncIterator[Frame]:
    yield Frame(kind="x")


# Case B — route defined on a router, then `include_router`: broken
router = APIRouter()

@router.post("/s", response_class=EventSourceResponse)
async def b() -> AsyncIterator[Frame]:
    yield Frame(kind="x")


app_b = FastAPI()
app_b.include_router(router)

print("DIRECT:", app_a.routes[-1].stream_item_type)
print("ROUTER (pre-include):", router.routes[-1].stream_item_type)
print("APP (post-include):", app_b.routes[-1].stream_item_type)


def has_content_schema(spec: dict) -> bool:
    sse = spec["paths"]["/s"]["post"]["responses"]["200"]["content"]["text/event-stream"]
    return "contentSchema" in sse.get("itemSchema", {}).get("properties", {}).get("data", {})


print("DIRECT openapi has contentSchema:", has_content_schema(app_a.openapi()))
print("INCLUDE_ROUTER openapi has contentSchema:", has_content_schema(app_b.openapi()))

---

DIRECT: <class '__main__.Frame'>
ROUTER (pre-include): <class '__main__.Frame'>
APP (post-include): None

DIRECT openapi has contentSchema: True
INCLUDE_ROUTER openapi has contentSchema: False

---

if isinstance(response_model, DefaultPlaceholder):
    return_annotation = get_typed_return_annotation(endpoint)
    if lenient_issubclass(return_annotation, Response):
        response_model = None
    else:
        stream_item = get_stream_item_type(return_annotation)
        if stream_item is not None:
            if (
                isinstance(response_class, DefaultPlaceholder)
                or lenient_issubclass(response_class, EventSourceResponse)
            ) and not lenient_issubclass(stream_item, ServerSentEvent):
                self.stream_item_type = stream_item
            response_model = None
        else:
            response_model = return_annotation
self.response_model = response_model

---

self.add_api_route(
    prefix + route.path,
    route.endpoint,
    response_model=route.response_model,  # now None, not DefaultPlaceholder
    ...
    response_class=use_response_class,
    ...
)
RAW_BUFFERClick to expand / collapse

Summary

When an SSE route (response_class=EventSourceResponse + AsyncIterator[Frame] return annotation) is defined on an APIRouter and then merged onto a FastAPI app via include_router, the resulting merged route loses its stream_item_type. As a consequence, the OpenAPI text/event-stream response schema omits the data.contentSchema, so tooling like datamodel-codegen cannot generate frame models.

Defining the same route directly on the FastAPI app via @app.post(...) works correctly.

Reproduction

from collections.abc import AsyncIterator

from fastapi import APIRouter, FastAPI
from fastapi.sse import EventSourceResponse
from pydantic import BaseModel


class Frame(BaseModel):
    kind: str


# Case A — route registered directly on the app: works
app_a = FastAPI()

@app_a.post("/s", response_class=EventSourceResponse)
async def a() -> AsyncIterator[Frame]:
    yield Frame(kind="x")


# Case B — route defined on a router, then `include_router`: broken
router = APIRouter()

@router.post("/s", response_class=EventSourceResponse)
async def b() -> AsyncIterator[Frame]:
    yield Frame(kind="x")


app_b = FastAPI()
app_b.include_router(router)

print("DIRECT:", app_a.routes[-1].stream_item_type)
print("ROUTER (pre-include):", router.routes[-1].stream_item_type)
print("APP (post-include):", app_b.routes[-1].stream_item_type)


def has_content_schema(spec: dict) -> bool:
    sse = spec["paths"]["/s"]["post"]["responses"]["200"]["content"]["text/event-stream"]
    return "contentSchema" in sse.get("itemSchema", {}).get("properties", {}).get("data", {})


print("DIRECT openapi has contentSchema:", has_content_schema(app_a.openapi()))
print("INCLUDE_ROUTER openapi has contentSchema:", has_content_schema(app_b.openapi()))

Output:

DIRECT: <class '__main__.Frame'>
ROUTER (pre-include): <class '__main__.Frame'>
APP (post-include): None

DIRECT openapi has contentSchema: True
INCLUDE_ROUTER openapi has contentSchema: False

Expected

After include_router, the merged route on app_b should carry the same stream_item_type as the source route, and the emitted OpenAPI should include the contentSchema under responses.200.content["text/event-stream"].itemSchema.properties.data referencing Frame.

Actual

The merged route's stream_item_type is None; the emitted OpenAPI shows the generic SSE envelope (data: string, event, id, retry) with no contentSchema.

Root cause (proposed)

In fastapi/routing.py, APIRoute.__init__ populates self.stream_item_type only inside the branch guarded by isinstance(response_model, DefaultPlaceholder):

if isinstance(response_model, DefaultPlaceholder):
    return_annotation = get_typed_return_annotation(endpoint)
    if lenient_issubclass(return_annotation, Response):
        response_model = None
    else:
        stream_item = get_stream_item_type(return_annotation)
        if stream_item is not None:
            if (
                isinstance(response_class, DefaultPlaceholder)
                or lenient_issubclass(response_class, EventSourceResponse)
            ) and not lenient_issubclass(stream_item, ServerSentEvent):
                self.stream_item_type = stream_item
            response_model = None
        else:
            response_model = return_annotation
self.response_model = response_model

When the source route (on the APIRouter) is created, response_model starts as a DefaultPlaceholder, the detection runs, and self.response_model is overwritten to None.

APIRouter.include_router then creates the merged route with:

self.add_api_route(
    prefix + route.path,
    route.endpoint,
    response_model=route.response_model,  # now None, not DefaultPlaceholder
    ...
    response_class=use_response_class,
    ...
)

On the merged route's __init__, isinstance(response_model, DefaultPlaceholder) is now False, so the detection branch is skipped and self.stream_item_type stays at its initial None.

The symmetric issue likely affects JSONL streaming (response_class=DefaultPlaceholder path) too, since it's gated by the same DefaultPlaceholder check on response_model.

Suggested fix

Either:

  1. In include_router, additionally copy stream_item_type onto the new route (simplest, closest to the current wiring).
  2. In APIRoute.__init__, run stream-item detection regardless of whether response_model is a DefaultPlaceholder, as long as response_model is None after the conditional.

Environment

  • fastapi==0.135.2
  • pydantic==2.12.5
  • starlette==1.0.0
  • Python 3.11.6
  • macOS 14.4.1

Happy to open a PR if the preferred fix direction is specified.

extent analysis

TL;DR

The most likely fix is to modify the APIRoute.__init__ method to run stream-item detection regardless of whether response_model is a DefaultPlaceholder, or to copy stream_item_type onto the new route in include_router.

Guidance

  • The issue is caused by the stream_item_type not being copied when a route is merged onto a FastAPI app via include_router.
  • To fix this, you can either modify the APIRoute.__init__ method to run stream-item detection when response_model is None, or copy stream_item_type onto the new route in include_router.
  • Verify the fix by checking the stream_item_type of the merged route and the emitted OpenAPI schema.
  • The fix should also be applied to JSONL streaming, as it is affected by the same issue.

Example

# Modified APIRoute.__init__ method
if response_model is None:
    return_annotation = get_typed_return_annotation(endpoint)
    stream_item = get_stream_item_type(return_annotation)
    if stream_item is not None:
        self.stream_item_type = stream_item

Notes

  • The issue is specific to FastAPI version 0.135.2 and may be fixed in later versions.
  • The suggested fix may have implications for other parts of the FastAPI codebase and should be thoroughly tested before being merged.

Recommendation

Apply the workaround by modifying the APIRoute.__init__ method to run stream-item detection when response_model is None, as this is the simplest and most targeted fix.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING