Skip to content

Add support for trio to client - #458

Merged
anuraaga merged 10 commits into
connectrpc:mainfrom
anuraaga:client-trio
Nov 11, 2026
Merged

anuraaga merged 10 commits into
connectrpc:mainfrom
anuraaga:client-trio

Conversation

@anuraaga

Copy link
Copy Markdown
Collaborator

A large part of this is parametrizing anyio tests (instead of pytest-asyncio) to run on both loops, similar to pyqwest / pyvoy. I also went ahead and replaced hypercorn in unit tests with ASGI transport

Signed-off-by: Anuraag Agrawal <anuraaga@gmail.com>
Signed-off-by: Anuraag Agrawal <anuraaga@gmail.com>
Signed-off-by: Anuraag Agrawal <anuraaga@gmail.com>
Signed-off-by: Anuraag Agrawal <anuraaga@gmail.com>
Signed-off-by: Anuraag Agrawal <anuraaga@gmail.com>
Comment thread src/connectrpc/_loop.py Outdated
except RuntimeError:
# Not asyncio, so trio: the only other event loop supported.
from ._server_trio import TrioLoop # noqa: PLC0415
from ._loop_trio import TrioLoop # noqa: PLC0415

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we ought to pick the loop the same way pyqwest does (sniffio), since right now the two can disagree. e.g. trio guest mode on an asyncio host: sniffio says trio, but get_running_loop() succeeds so we hand back AsyncioLoop, and every unary call fails, deadline or not:

import asyncio

import sniffio
import trio
from pyqwest import Client
from pyqwest.testing import ASGITransport

from connectrpc._loop import current_loop
from test.connectrpc.example.haberdasher_connect import Haberdasher, HaberdasherASGIApplication, HaberdasherClient
from test.connectrpc.example.haberdasher_pb import Hat, Size


class Fast(Haberdasher):
    async def make_hat(self, request, ctx):
        return Hat(size=request.inches)


async def trio_main():
    print(sniffio.current_async_library(), type(current_loop()).__name__)  # trio AsyncioLoop
    app = HaberdasherASGIApplication(Fast())
    async with HaberdasherClient("http://localhost", http_client=Client(ASGITransport(app))) as client:
        # ConnectError: INTERNAL 'Timeout should be used inside a task', with or without timeout_ms
        await client.make_hat(Size(inches=3))


async def host():
    loop = asyncio.get_running_loop()
    done = loop.create_future()
    trio.lowlevel.start_guest_run(
        trio_main,
        run_sync_soon_threadsafe=loop.call_soon_threadsafe,
        done_callback=lambda outcome: loop.call_soon_threadsafe(done.set_result, outcome),
    )
    print(await done)


asyncio.run(host())

Probably pretty niche, but sniffio.current_async_library() plus a module-level TrioLoop is also a fair bit cheaper on trio (~50ns + ~17ns vs ~490ns for the raise/catch and a new TrioLoop per call). (The server already detects the loop this way, so that side predates this PR.)

Comment thread test/test_server_trio.py Outdated
# ASGITransport cancels the application once the client has the
# whole response. A server lets it finish, so the shield blocks
# that cancellation.
with trio.CancelScope(shield=True), trio.CancelScope() as self._scope:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the app runs shielded, nothing outside can stop a hung handler. Tried a handler that just does await trio.sleep_forever() behind Wrapper: the fail_after(1) around the call never fires, and the test sits there until pytest-timeout (1800s here). Without the wrapper it fails after ~1s with TooSlowError. Maybe a trio.fail_after inside the shield, so a hang still fails fast?

Comment thread src/connectrpc/_client_async.py Outdated
finally:
# A call can end before the request stream does, for example when the
# server responds early.
await _aclose(msgs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closing the request stream seems right for generators (on main their finally doesn't run until GC), but _aclose closes anything with an aclose, including things the caller owns. e.g. pass the receive side of an anyio memory stream to a client-stream call that the server ends early, and the producer's next send raises BrokenResourceError. Not sure what we want here; maybe only close async generators (inspect.isasyncgen)?

Signed-off-by: Anuraag Agrawal <anuraaga@gmail.com>
@anuraaga

Copy link
Copy Markdown
Collaborator Author

Thanks - fixed them

Signed-off-by: Anuraag Agrawal <anuraaga@gmail.com>
Signed-off-by: Anuraag Agrawal <anuraaga@gmail.com>

@stefanvanburen stefanvanburen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks, LGTM!

@anuraaga
anuraaga merged commit 5ccf5e9 into connectrpc:main Nov 11, 2026
25 checks passed
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