Building Pyrseia II: Fleshing out Clients and Servers

This is the second article in the Pyrseia series. The others are:

If you want to follow along with the code, this article refers to commit 2db40614bd926d1ef2854669a634fcfa3ba25502.

Decoupling the API from the Client

The last time we checked in with our small Calculator project, the client and API parts were intertwined, and looked like this:

@client
class CalculatorRpc:
    @rpc
    async def add(self, a: int, b: int) -> int:
        ...

Since the server needs to depend on the API too, but we don't want the server to depend on the client, let's split up the API into a separate class.

api.py:

from pyrseia import rpc

class Calculator:
    @rpc
    async def add(self, a: int, b: int) -> int:
        ...

client.py:

from pyrseia import create_client
from pyrseia.httpx import httpx_client_adapter

async def create_calculator_client(url: str) -> Calculator:
    return await create_client(httpx_client_adapter(url))

Off the bat, creating a client is an async operation now, because the client might need to initialize using the event loop.

Connecting the Client to the network

Creating a client returns a subclass of the API that you can actually use to perform calls. Ideally, we would use Python's type system to stop you accidentally calling the API class directly (i.e. calling api.Calculator().add(1, 2) rather than client.add(1, 2)), but I haven't figured out how to do this yet. (Abstract base classes don't quite fit due to Mypy limitations.)

Following a component-based approach, creating a client requires a client network adapter. The exact type of this argument is AsyncContextManager[ClientAdapter], and ClientAdapter is just a type alias for Callable[[bytes], Awaitable[bytes]]. This looks scary, so let's untangle it.

Essentially, the client needs a component that it can give bytes to and await it to get bytes back. This is exactly described with Callable[[bytes], Awaitable[bytes]]. If you had an instance of this type, you'd use it like this:

async def use(something: Callable[[bytes], Awaitable[bytes]]):
    res = await something(b"")
    # res is an instance of bytes.

As we mentioned, this is aliased as (pyrseia.)ClientAdapter to save on typing (pun intended).

So what's the deal with this AsyncContextManager[ClientAdapter] then? Async context managers are basically a more convenient way of making async factories. The client doesn't just need a ClientAdapter, it needs a way to create, asynchronously, a client adapter on every request. If someone handed you an instance of AsyncContextManager[ClientAdapter], here's how you'd use it:

async def use_again(something: AsyncContextManager[ClientAdapter]):
    async with something as client_adapter:
        res = await client_adapter(b"")
        # res is bytes.

Now, maybe not every network library needs to async create a client on every request, but this is a good lowest common denominator. Using this API, we can basically adapt any network protocol library to our clients. Within reason.

I've written two network adapters for our clients: pyrseia.httpx.httpx_client_adapter and pyrseia.aiohttp.aiohttp_client_adapter. Here's the source of the httpx adapter, just for you to get a taste (imports elided):

def httpx_client_adapter(
    url: str, timeout: Optional[int] = None
) -> AsyncContextManager[ClientAdapter]:
    @asynccontextmanager
    async def adapter() -> AsyncGenerator[ClientAdapter, None]:

        async with AsyncClient(timeout=timeout) as client:

            async def sender(payload: bytes) -> bytes:
                res = await client.post(url, data=payload)
                return res.content

            yield sender

    return adapter()

I'm not going to claim this is super simple - there's a lot of complexity in these few lines of code. There's also a lot of power. Hopefully it could be a good starting point if you want to write your own someday. (As long as we keep this API 😉.)

Again, with the proper type annotations, Mypy can check all of this. If your sender, for example, takes a str instead of bytes by accident, Mypy will disallow it.

So, given a server URL, we can create and use the client to access our remote Calculator implementation. And we can pick between two HTTP libraries to do so! There's also an async pyrseia.close_client you're supposed to await to gracefully close your clients.

Servers and Request Contexts

The server component has become generic with respect to two types: the actual API that it's implementing, and a request context class.

A request context is metadata that's specific to every instance of an incoming request. A simple example of request metadata is the remote IP address of the client, although that's HTTP/TCP specific. For example, if the server was connected to a message bus (like Redis pubsub or Kafka) instead of an HTTP endpoint, the request context might not have this particular piece of information.

Since we want the server to be very versatile, we let the creator of the server pick the exact request context class.

So let's say you decide to expose a server for the Calculator interface using HTTP, and you want to use Starlette to be the bridge to the outside world (or the source of requests, depending on how lyrical you're feeling). A good choice for a request context class would then be starlette.requests.Request.

So how does your code use the request context? When you write an implementation for an @rpc endpoint, the first argument to your coroutine can, optionally, be an instance of the request context class. So you could write:

server.py:

from pyrseia import server
from starlette.requests import Request

from .api import Calculator
serv = server(Calculator, ctx_cls=Request)  # type: Server[Calculator, Request]

@serv.implement(Calculator.add)
async def add(ctx: Request, a: int, b: int) -> int:
    print(ctx.client)  # Print the IP address and port of the requester.
    return a + b

This is also checked by Mypy!

So how do we actually serve our server? Like this.

app.py:

from pyrseia.starlette import create_starlette_app
from .server import serv

app = create_starlette_app(serv)

and use Uvicorn to start it:

$ uvicorn app:app

create_starlette_app takes a server of type Server[Any, starlette.requests.Request, so Mypy will also make sure you connect these together properly, if you use it. You won't be able to create a Starlette app from a server that expects an aiohttp request.

Speaking of aiohttp, I've also written a helper to create an aiohttp app (pyrseia.aiohttp.create_aiohttp_app) so you have your choice of server tech. Aiohttp apps aren't ASGI apps so they can't be run with Uvicorn, though.

You might want to use your own class for the request context though. Easy bridging what your network runners provide and what you want will be one of the next steps for Pyrseia.

Does this actually work, though?

It does! I've written tests in the repository for all of these adapters (although I use Hypercorn to run the Starlette app), and they work just fine. You could write an app using Pyrseia now!

Pyrseia is still missing a few crucial pieces, though. We don't have control over the wire format, so interoperability is still tricky. Until we deal with this in a flexible way, Pyrseia will only work with itself. We have loftier goals than that, though. We should also consider how to transfer errors/exceptions over the wire.

Stay tuned!

Tin
Zagreb, Croatia