Skip to content

Conversation

@mmacpherson
Copy link

@mmacpherson mmacpherson commented Nov 23, 2025

(PR statement heavily revised after initial submission; see comment below.)

Problem

@datastar_response returned a coroutine for sync handlers, so frameworks treated them as async and ran blocking work on the event loop. That stalls unrelated requests under load and produces “coroutine was never awaited” warnings if you call the decorated function directly.

Fix

Keep the wrapper sync and normalize the result: async iterable → DatastarResponse, sync iterable → DatastarResponse, awaitable → tiny async gen that awaits once → DatastarResponse, else wrap the value. Applied consistently across starlette/fastapi/fasthtml/litestar/django/sanic.

Sync handlers now stay in the threadpool; return shape is always DatastarResponse.

(sanic streams generators via request.respond; returns None there)

Tests

  • Matrix unit tests for {sync, async} × {value, generator} across the adapters (Django async gen skipped).
  • Integration tests (uvicorn + httpx) for Starlette/FastAPI/FastHTML/Litestar covering all four handler shapes; assert ping isn’t stalled by a blocking sync handler and SSE payload markers are present.
  • Runtime/concurrency test for sync-callable behavior and threadpool vs event-loop.

Docs

README notes the decorator preserves sync semantics.

Verification

All tests are green, warnings are upstream deprecations (litestar/websockets/py3.14).

make test  # which runs uv run --dev pytest

@mmacpherson mmacpherson force-pushed the fix-async-generator-decorator branch from c1e0d12 to 2d48f42 Compare November 23, 2025 00:11
@gazpachoking
Copy link
Collaborator

Thanks for this. Just want to verify what it's fixing first though, what were the cases that weren't working as expected?

@mmacpherson mmacpherson force-pushed the fix-async-generator-decorator branch from 2d48f42 to 2235325 Compare November 25, 2025 19:13
@mmacpherson
Copy link
Author

mmacpherson commented Nov 25, 2025

@gazpachoking (Thanks for maintaining this and) thanks for the question; I should have been clearer, and this prodded me to get more specific about the issue.

I had initially said the decorator ‘didn’t work as expected’, which was too broad; the narrower issue is it returned a coroutine for sync handlers, so blocking work ran on the event loop. I use fasthtml, and I think I experienced that as the decorator "not working", when it was more likely things bogging down for me under load because of the sync/async issue.

In this updated PR, I add some "matrix" tests, that establish that the decorator works/has worked for {sync, async} x {generator, not-generator} x {frameworks}. But I also add a test that shows the async/sync issue. Matrix tests pass under 0.7.0; the concurrency test fails under 0.7.0 and passes with this fix.

The third commit makes minor updates to the docs/egs.

I'm no expert in async/sync stuff, just a fasthtml + datastar user.

@gazpachoking
Copy link
Collaborator

Hmm. I see, I hadn't considered that but I guess transforming a sync handler into async could be surprising. Don't we have the opposite problem with this fix though, even async handlers will be turned into sync ones, thus causing a thread to be spawned?

@gazpachoking
Copy link
Collaborator

gazpachoking commented Dec 19, 2025

@mmacpherson What about something like this?

@overload
def datastar_response(
    func: Callable[P, Coroutine[DatastarEvent | None, None, DatastarEvents | None]],
) -> Callable[P, Coroutine[None, None, DatastarResponse]]: ...
@overload
def datastar_response(func: Callable[P, DatastarEvents]) -> Callable[P, DatastarResponse]: ...
def datastar_response(
    func: Callable[P, Coroutine[DatastarEvent | None, None, DatastarEvents] | DatastarEvents],
) -> Callable[P, Coroutine[None, None, DatastarResponse] | DatastarResponse]:
    """A decorator which wraps a function result in DatastarResponse.

    Can be used on a sync or async function or generator function.
    """

    if inspect.iscoroutinefunction(func):

        @wraps(func)
        async def wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
            return DatastarResponse(await func(*args, **kwargs))

    else:

        @wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
            return DatastarResponse(func(*args, **kwargs))


    wrapper.__annotations__["return"] = DatastarResponse
    return wrapper

This method would keep the function sync or async as the user defined it. Want to try this out?

@mmacpherson mmacpherson force-pushed the fix-async-generator-decorator branch from 75b6079 to 598e862 Compare December 20, 2025 22:26
@mmacpherson
Copy link
Author

@gazpachoking Thanks for this—you're right that wrapping async handlers in sync wrappers causes unnecessary threadpool overhead.

However, I noticed a gap in the proposed fix: inspect.iscoroutinefunction() returns False for async generators (async def ... yield), so they would fall back to the sync wrapper. Also, functools.partial objects often fail inspection checks.

I’ve refined the approach to unwind functools.partial and then split the logic into three distinct wrappers at decoration time. This preserves the exact sync/async nature of the handler:

    # Unwrap partials to inspect the actual underlying function
    actual_func = func
    while isinstance(actual_func, partial):
        actual_func = actual_func.func

    # Case A: Async Generator (async def + yield)
    if isasyncgenfunction(actual_func):
        @wraps(actual_func)
        async def async_gen_wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
            return DatastarResponse(func(*args, **kwargs))
        
        async_gen_wrapper.__annotations__["return"] = DatastarResponse
        return async_gen_wrapper

    # Case B: Standard Coroutine (async def + return)
    elif iscoroutinefunction(actual_func):
        @wraps(actual_func)
        async def async_coro_wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
            result = await func(*args, **kwargs)
            return DatastarResponse(result)

        async_coro_wrapper.__annotations__["return"] = DatastarResponse
        return async_coro_wrapper

    # Case C: Sync Function (def)
    else:
        @wraps(actual_func)
        def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
            return DatastarResponse(func(*args, **kwargs))

        sync_wrapper.__annotations__["return"] = DatastarResponse
        return sync_wrapper

I've verified that all the tests proposed in this PR pass with this change. Thoughts?

@gazpachoking
Copy link
Collaborator

I haven't forgotten this, and think it's worth fixing. I just want to get some time to test things out myself as well.

Also, functools.partial objects often fail inspection checks.

I’ve refined the approach to unwind functools.partial and then split the logic into three distinct wrappers at decoration time

Hmm, both iscoroutinefunction and isasyncgenfunction both call out explicitly in the docs that they detect functools.partial methods. But more importantly, what's the situation that someone would be applying a decorator to a partial function? Just trying to simplify the implementation.

Also, can't both the coroutine and async generator case follow the same code branch? (The same behavior we have now.) Just the sync case needs to be split out I think.

- remove unnecessary partial function check
- exchange deco-time async generator vs coroutine check for runtime
- make analogous changes for each supported framework
- update test docstring to reflect implementation
@mmacpherson
Copy link
Author

Thanks for the patience and the feedback! You're right on both counts.

  1. Partials: Thanks—confirmed that inspect.iscoroutinefunction and
    isasyncgenfunction have natively handled functools.partial since Python 3.8+.
    I removed the manual unwrapping logic entirely.

  2. Merging Branches: I merged the async cases for most frameworks.
    A single async def wrapper with an isawaitable(result) runtime check
    handles both coroutines (await them) and async generators (pass through).

This reduces most implementations to two paths: Sync (threadpool) and
Async (event loop).

Note: Quart needs to keep the async generator branch separate because of
stream_with_context(), which must wrap the function at decoration time.
Django already has only two branches (async generators raise NotImplementedError).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants