Primer on WebSockets and JSON-RPC

This section provides a brief introduction to the WebSocket and JSON-RPC protocols used for communication between recsystems and the Renewal backend, as well as to asynchronous I/O programming techniques (in particular on Python using asyncio, though the general concepts are transferrable to other programming languages which support asynchronous I/O). If you are already familiar with asyncio, you can skip straight to the WebSockets primer.

Asynchronous I/O with asyncio

While it is not strictly necessary to understand or even use asynchronous I/O to implement a recsystem, in practice you will find that due to the event-driven nature of WebSockets many modern programming language interfaces for dealing with WebSockets, such as on Python and JavaScript, use asynchronous I/O techniques. In particular, the renewal_recsystem package itself uses Python’s asyncio library throughout, so understanding how it works requires a minimum of background learning, especially if you have never seen the async/await syntax before.

In particular, all of the below examples of WebSockets and JSON-RPC use Python and asyncio, so it useful to provide this additional primer.

A full introduction to asynchronous I/O with coroutines is beyond the scope of this documentation, but Async IO in Python: A Complete Walkthrough is a good, thorough explainer.

At a bare minimum you need to keep in mind the following points:

  • A function defined using the async keyword is a coroutine function. For example:

    >>> import asyncio
    >>> async def ticker(ticks=5, interval=1):
    ...     for n in range(ticks):
    ...         print(f'tick {n}')
    ...         await asyncio.sleep(interval)
    ...
    
  • Coroutines look like normal functions (except the async keyword) but they are not called like normal functions. If you try to call a coroutine like a normal function, it won’t run, and you’ll just get back the resulting coroutine object:

    >>> ticker()  
    <coroutine object ticker at 0x7f5f780559e0>
    
  • To run a coroutine function, you have to prefix its call with the await keyword, like await ticker(). However, you can’t use await just anywhere, like at the command prompt, or in a normal function. For example, if you try to do this in the Python interactive prompt you’ll just get:

    >>> await ticker()  
      File "<stdin>", line 1
    SyntaxError: 'await' outside function
    

    Note

    Unless you are using recent versions of IPython, which has a special feature allowing you to use await in interactive prompts.

  • In order to use the await keyword, you have to be inside another coroutine function defined with async. For example:

    >>> async def run_ticker():
    ...     print('starting ticker coroutine')
    ...     await ticker()
    ...     print('finished ticker coroutine')
    ...
    
  • In order to call an async function from synchronous code, i.e. from outside an async function, you have to pass the coroutine to the event loop which runs the coroutine until completion. The easiest way to do this as of Python 3.7 is to call:

    >>> import asyncio
    >>> asyncio.run(run_ticker())
    starting ticker coroutine
    tick 0
    tick 1
    tick 2
    tick 3
    tick 4
    finished ticker coroutine
    

    This asyncio.run call is roughly equivalent to:

    >>> loop = asyncio.get_event_loop()  
    >>> loop.run_until_complete(run_ticker())  
    >>> loop.close()  
    

    Note that in both cases we did not put await in front of run_ticker(). Instead, this function is just passed the coroutine object that is returned when it is called without await. This is the one case where you would not run an async function without await–to kick off the event loop which runs all the coroutines.

To summarize:

  • async/await always go together: If a function is defined with async you must call it with await (unless passing it directly to an event loop). Conversely, to use the await keyword you must be in an async function.

  • An event loop is responsible for running async functions, i.e. coroutines. To kick off the process of running async functions you will typically wrap them in a “main” async function which is passed to the event loop.

Note

In some languages, such as JavaScript, coroutines are implicitly scheduled on the event loop. That is, the event loop is always running, and if call an async function without await it will be scheduled to run on the event loop, which can lead to confusing and hard to debug errors. On Python, however, you must explicitly run a coroutine on the event loop.

Common mistakes

Here are some common mistakes in programming with async/await in Python and their symptoms.

Forgetting to await an async function:

>>> async def run_ticker():
...     print('starting ticker coroutine')
...     ticker()
...     print('finished ticker coroutine')
...
>>> asyncio.run(run_ticker())
starting ticker coroutine
finished ticker coroutine

In this case you get the warning RuntimeWarning: coroutine 'ticker' was never awaited and you can see there is no output from ticker().

Forgetting to use await inside an async function:

>>> def run_ticker():  
...     print('starting ticker coroutine')
...     await ticker()
...     print('finished ticker coroutine')
...
  File "<stdin>", line 3
SyntaxError: 'await' outside async function

Trying to run a non-async function on the event loop:

>>> def run_ticker():
...     print('starting ticker coroutine')
...     ticker()
...     print('finished ticker coroutine')
...
>>> asyncio.run(run_ticker())
Traceback (most recent call last):
...
ValueError: a coroutine was expected, got None

WebSockets

Traditionally, communication between a Web server and a client connecting to it is stateless and mostly one-directional: A client connects to the Web server, requests a resource (e.g. an HTML page or a RESTful API), and is returned a response.

WebSockets allow a traditional HTTP request to be “upgraded” to a long-running bi-directional communication channel, where both the client and server can send messages to each other and receive responses until one side closes the connection. The contents of the messages sent over WebSockets can contain anything, so it is up to the application to determine a protocol over WebSockets that the client and server will use.

Typically you will connect to a server supporting WebSockets using a software library which supports it, using a URI with the protocol prefix ws:// or wss:// (for secure connections). A successful connection will return an object representing that connection, on which you can send() messages to the server and recv() (receive) responses. The exact APIs vary, but they typically follow this design.

Websockets example

For example, the Python websockets package provides a simple WebSocket client interface, which can be used roughly like:

import websockets
websocket = websockets.connect('ws://example.com/websocket')
# send a greeting to the server
websocket.send('Hello')
# receive and print the response from the server
print(websocket.recv())
# close the connection
websocket.close()

In fact, the websockets package uses asyncio so the real usage requires await on all these calls. So let’s try a real-world example using both a server and a client. The websockets package also includes a simple WebSockets server. The easiest way to run these examples is probably to open two terminals side-by-side, one for the server and one for the client. We will create a simple echo server in which everything we say to the server will be echoed back to the client.

First, make sure you have the websockets package installed:

$ pip install websockets

Now the server code. To implement the server we define a “handler” async function. This function is run every time a client connects to our server and defines how the server communicates to each client over the WebSocket. It takes as its sole argument a websocket object which is passed to it when the client connects. It runs a loop until the client disconnects:

>>> import websockets
>>> async def handler(websocket):
...     while True:
...         # receive a message from the client
...         message = await websocket.recv()
...         # echo the message back to the client
...         await websocket.send(message)
...

To start the server we create a simple wrapper that starts the server, on a given port, and then waits for the server to finish (which should be never unless an error occurs). If you pass port=0 it will automatically pick a free port on your system:

>>> async def run_server(handler, host='localhost', port=0):
...     server = await websockets.server(handler, host=host, port=port)
...     # if port==0 we need to find out what port it's actually
...     # serving on as shown below:
...     port = server.sockets[0].getsockname()[1]
...     print(f'server running on ws://{host}:{port}')
...     await server.wait_closed()
...

Finally, start the server like so, optionally providing a port like port=9090:

>>> import asyncio
>>> asyncio.run(run_server(handler, port=9090))  
server running on ws://localhost:9090

Next on the client side, we can simply connect() to the server, send some messages and receive their echoes, and exit:

>>> import websockets, asyncio
>>> async def client(uri):
...     websocket = await websockets.connect(uri)
...     async def send_recv(msg):
...         print(f'-> {msg}')
...         await websocket.send(msg)
...         resp = await websocket.recv()
...         print(f'<- {resp}')
...
...     await send_recv("Hello!")
...     await send_recv("Goodbye!")
...     await websocket.close()
...

Now run the client() function passing it the port used for the server, for example:

>>> asyncio.run(client('ws://localhost:9090'))  
-> Hello!
<- Hello!
-> Goodbye!
<- Goodbye!

WebSockets programming for real applications proceed more-or-less in the same fashion, though for complex applications it is necessary to establish a protocol over which the client and server communicate. Typically one side opens with an initial message to which the other side responds. Then they take turns sending messages back and forth, the next message often determined by the contents of the previous message, like any conversation.

JSON-RPC

JSON-RPC is a simple protocol for making remote procedure calls (RPC) using JSON-encoded messages. JSON-RPC is not specific to WebSockets, and can be used over any transport mechanism. Renewal uses JSON-RPC to provide structure to the WebSocket communications between the Renewal backend and your recsystem.

With JSON-RPC there is a “server” side which provides a number of functions or “methods” which are executed by the server, and which may produce a result. And there is a “client” side which makes remote procedure calls of the methods provided by the server.

JSON-RPC has two types of methods that a server can implement: “requests” are methods that return a result to the client, whereas “notifications” are just for the client to send some notification to the server, and they do not return a response.

Say, for example, our JSON-RPC server implements a square(x) method which can be called via RPC:

def square(x):
    return x * x

Then in order to call this method, a client will send a message to the server like:

{"jsonrpc": "2.0", "method": "square", "params": [4], "id": 3}

The server will execute square(4) and upon completion return the following result to the server:

{"jsonrpc": "2.0", "result": 16, "id": 3}

Each request and response come with a unique “id” which allows responses to be matched up with the corresponding request (this allows the client to send many requests to the server, which does not necessarily have to respond to requests in the same order it received them).

While JSON-RPC is relatively easy to implement by hand, there are libraries that help converting function calls to correctly-formatted JSON-RPC requests and responses. For example, the renewal_recsystem package uses the jsonrpcserver package for Python to implement the base recsystem, and the Renewal backend uses its sister package jsonrpcclient to make RPC calls.

JSON-RPC example

Here’s an example of how JSON-RPC can be used over WebSockets, building on our previous example from the WebSockets primer.

In this case the WebSocket client will act as the JSON-RPC server (it provides the functions to run), and the WebSocket server will act as the JSON-RPC client (it will make the RPC calls). This may seem counter-intuitive but in fact models how communication between the Renewal backend and recsystems works.

As in the WebSockets primer, these examples are easiest to run in two separate terminals side-by-side. One for the WebSocket server (JSON-RPC client) side, and one for the WebSocket client (JSON-RPC server) side.

First make sure you have the websockets package installed, as well as the JSON-RPC client for websockets and the jsonrpcserver package:

$ pip install websockets jsonrpcclient[websockets] jsonrpcserver

WebSocket server side

As before, we must create a handler function which describes what the WebSocket server will do when a client connects to it. In this case it will simply greet the client by sending the greeting() notification RPC, and then it will request the square of 42 by calling the square() RPC and print the result, then close the connection:

>>> import websockets
>>> from jsonrpcclient.clients.websockets_client import WebSocketsClient
>>> async def handler(websocket):
...     # create a WebSocketsClient wrapping the websocket connection
...     rpc_client = WebSocketsClient(websocket)
...
...     # use the notify() method by passing it the name of the
...     # notification RPC and any arguments it takes
...     await rpc_client.notify("greeting", "Hello, friend!")
...
...     # use the request() method the same way, but it returns a
...     # a response object
...     response = await rpc_client.request("square", 42)
...
...     # print the result of the call
...     print(f"got square(42) = {response.data.result}")
...

Start the WebSockets server as before (e.g. on port 9090):

>>> async def run_server(handler, host='localhost', port=0):
...     server = await websockets.server(handler, host=host, port=port)
...     # if port==0 we need to find out what port it's actually
...     # serving on as shown below:
...     port = server.sockets[0].getsockname()[1]
...     print(f'server running on ws://{host}:{port}')
...     await server.wait_closed()
...
>>> import asyncio
>>> asyncio.run(run_server(handler, port=9090))  
server running on ws://localhost:9090

WebSocket client side

The WebSocket client acts as a JSON-RPC server: It provides a few methods that can be called via RPC. When it connects to the server, in this case, the server will immediately call those methods and then close the connection (in the case of the actual Renewal backend it keeps the connection open indefinitely and continues to send notifications and requests to your recsystem as long as both ends are running).

The jsonrpcserver package provides a @method decorator that we can put on top of the definition of any function that we want to be callable via RPC. In this case we define greeting() and square(). Note in this case we are using the “async dispatcher”, so all functions must be defined with async even if they don’t use await:

>>> from jsonrpcserver import method
>>> @method
... async def greeting(message):
...     print(f'Received a greeting from the client: {message}')
...
>>> @method
... async def square(x):
...     result = x * x
...     print(f'Squaring {x} for the client -> {result}')
...     return result
...

Now define a function to connect to the WebSockets server. It waits to receive RPC calls from the server, and uses the dispatch() function which handles the RPC calls by passing them to the appropriate function from the ones we registered above:

>>> import websockets, asyncio
>>> from jsonrpcserver import async_dispatch as dispatch
>>> async def client(uri):
...     websocket = await websockets.connect(uri)
...     while True:
...         try:
...             message = await websocket.recv()
...         except websockets.ConnectionClosedOK:
...             # We will receive this exception when trying to receive
...             # more messages from the WebSocket after the server
...             # has closed the connection; so we just exit the loop
...             break
...         response = await dispatch(message)
...         # If response.wanted is False, the message contained a
...         # notification call, in which case we do
...         # not send a response to the other side.
...         if response.wanted:
...             await websocket.send(str(response))
...

Now run the client function. On the client side you should see the output as follows:

>>> asyncio.run(client('ws://localhost:9090'))  
Received a greeting from the client: Hello, friend!
Squaring 42 for the client -> 1764

While on the WebSocket server side you should see:

got square(42) = 1764

The above examples demonstrate in simplified form how your recsystem will communicate with the Renewal backend. In fact it does not require much more than that, though in practical application it can get a little more complicated; see the source code for renewal_recsystem.server for example. Its extra complexity arises from the fact that it can handle multiple simultaneous RPC calls. In the example above our RPC server just takes once RPC at a time and sends a result in serial. Whereas the implementation in renewal_recsystem.server allows it to handle many RPC calls simultaneously and send their results as the RPC handler functions complete.

All you have to do is implement the functions described in JSON-RPC API and the rest of the framework will take care of registering them as RPC methods.