An Asyncio socket tutorial

How to build an ASGI web server, like Hypercorn

There are many asyncio tutorials and articles that focus on coroutines, the event loop, and simple primitives. There are fewer that focus on using sockets, for either listening for or sending to connections. This article will show how to build a simple web server.

This is based on the development of Hypercorn, which is an ASGI server that supports HTTP/1, HTTP/2, and Websockets. The Hypercorn code is the follow on for this article.

Echo Server

An echo server is the simplest place to start, and by definition simply echos back to the client any data sent. As we are aiming to build a web server the TCP (Protocol) makes sense.

Asyncio has two high level choices for writing servers, either callback based or stream based. I think the latter is conceptually clearer, but has been shown to have worse performance. So we’ll do both, starting with stream based, (also note I’ll be using Python3.7 features, such as the serve_forever),

import asyncio
async def echo_server(reader, writer):
while True:
data = await reader.read(100) # Max number of bytes to read
if not data:
break
writer.write(data)
await writer.drain() # Flow control, see later
writer.close()
async def main(host, port):
server = await asyncio.start_server(echo_server, host, port)
await server.serve_forever()
asyncio.run(main('127.0.0.1', 5000))

If you run this code and then connect via telnet localhost 5000 or equivalent you should see the server echo back everything sent. The equivalent code using callbacks, termed a Protocol is,

import asyncio
class EchoProtocol(asyncio.Protocol):
    def connection_made(self, transport):
self.transport = transport
    def data_received(self, data):
self.transport.write(data)
async def main(host, port):
loop = asyncio.get_running_loop()
server = await loop.create_server(EchoProtocol, host, port)
await server.serve_forever()
asyncio.run(main('127.0.0.1', 5000))

HTTP Server

Now we are able to open a socket listen for connections and respond, we can add HTTP as the communication protocol and then have a webserver. To start with lets simply echo back the important parts of a HTTP message, i.e. the verb, request target, and any headers.

At this stage we need to read RFC 7230 and write a HTTP parser, or use one that already exists. The latter is much easier, and h11 is fantastic so we’ll use that instead.

Aside on h11

h11 is a sans-io library, this means that it manages a HTTP connection without managing the IO. In practice this means that any bytes received have to be passed to the h11 connection and any bytes to be sent have to be retrieved from the h11 connection which allows the h11 connection object to manage the HTTP state.

As an example a simple request response sequence is given below, note how the received bytes must be provided to h11 and how the response data is provided by h11,

import h11
connection = h11.Connection(h11.SERVER)
connection.receive_data(
b"GET /path HTTP/1.1\r\nHost: localhost:5000\r\n\r\n",
)
request = connection.next_event()
# request.method == b"GET"
# request.target == b"/path"
data = connection.send(h11.Response(status_code=200))
# data == b"HTTP/1.1 200"

Basic HTTP server

We’ll stick with the Protocol server, on the basis of performance, and add h11,

import asyncio
import h11
class HTTPProtocol(asyncio.Protocol):
    def __init__(self):
self.connection = h11.Connection(h11.SERVER)
    def connection_made(self, transport):
self.transport = transport
    def data_received(self, data):
self.connection.receive_data(data)
while True:
event = self.connection.next_event()
if isinstance(event, h11.Request):
self.send_response(event)
elif (
isinstance(event, h11.ConnectionClosed)
or event is h11.NEED_DATA or event is h11.PAUSED
):
break
if self.connection.our_state is h11.MUST_CLOSE:
self.transport.close()
    def send_response(self, event):
body = b"%s %s" % (event.method.upper(), event.target)
headers = [
('content-type', 'text/plain'),
('content-length', str(len(body))),
]
response = h11.Response(status_code=200, headers=headers)
self.send(response)
self.send(h11.Data(data=body))
self.send(h11.EndOfMessage())

def send(self, event):
data = self.connection.send(event)
self.transport.write(data)
async def main(host, port):
loop = asyncio.get_running_loop()
server = await loop.create_server(HTTPProtocol, host, port)
await server.serve_forever()
asyncio.run(main('127.0.0.1', 5000))

If you run this code and make a HTTP request, e.g. curl localhost:5000/path you will receive GET /path back.

Timeouts & Flow Control

Whilst the above gives a basic HTTP server, we should add flow control and timeouts to ensure it isn’t trivially attacked. These attacks typically attempt to exhaust the server’s resources (sockets, memory, cpu…) so that it can no longer serve new connections.

Timeouts

To start lets consider a malicious client that opens many connections to the server, and holds them open without doing anything. This exhausts the connections the server has, thereby preventing anyone else from connecting.

To combat this the server should timeout an idle connection, that is wait a certain length of time for the client to do something and then close the connection if it doesn’t. Using a protocol server this can be done as follows,

import asyncio
TIMEOUT = 1  # Second
class TimeoutServer(asyncio.Protocol):
    def __init__(self):
loop = asyncio.get_running_loop()
self.timeout_handle = loop.call_later(
TIMEOUT, self._timeout,
)
    def connection_made(self, transport):
self.transport = transport
    def data_received(self, data):
self.timeout_handle.cancel()
    def _timeout(self):
self.transport.close()

Flow Control

In the initial echo server example we had await writer.drain() as this paused the coroutine from writing more data to the socket till the client had caught up, it drained the socket. This is useful as until the client catches up the data will be stored in memory, hence a malicious client can make many requests for a lot of data, refuse to receive the data, and allow the server to exhaust its memory.

To combat this the coroutine sending data should await a drain function, that can be added to the protocol,

import asyncio
class FlowControlServer(asyncio.Protocol):
    def __init__(self):
self._can_write = asyncio.Event()
self._can_write.set()
    def pause_writing(self) -> None:
# Will be called whenever the transport crosses the
# high-water mark.
self._can_write.clear()
    def resume_writing(self) -> None:
# Will be called whenever the transport drops back below the
# low-water mark.
self._can_write.set()
    async def drain(self) -> None:
await self._can_write.wait()

Conclusion

This is really all there is with respect to asyncio to build an ASGI server. To continue you’ll need to add pipelining, ASGI constructs, the request-body, and streaming as completed in the Hypercorn h11.py file. See also the h2, and wsproto libraries for the HTTP/2 and Websocket equivalents of h11.