>>> 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
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.
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.
renewal_recsystem
API Documentation
Primer on WebSockets and JSON-RPC