Implement a socket-based service in Python with socketserver

Photo by Neven Krcmarek on Unsplash

You don’t need an HTTP application to make a service. Depending on your requirements, you might not need anything besides Python’s standard library. Implementing a socket-based service using the Python standard library socketserver module is straightforward with minimal introduction, which I plan to provide here.

Quick definition of terms, here. When I say service, in our current context I mean a program that accepts network-protocol connections from other (potentially remote) processes and handles their requests. When I first started creating services, I remember thinking I need an endpoint that I can hit with a request to [get something done]. That’s about it.

What I’ll cover

  • How does (what I’m calling) a socket-based service differ from an HTTP app?
  • What are the parts of a socket-based service?
  • What’s a basic implementation?
  • How about an async implementation?
  • What else might I consider?

How does this differ from an HTTP application?

What I’m calling a “socket-based service” is a lower-level network application than an HTTP app. In truth, HTTP applications are socket-based services because HTTP (HyperText Transfer Protocol) uses TCP (Transmission Control Protocol) to handle its connections and transfer of data. HTTP is a higher level of abstraction in network programming. (Read up on the OSI Model for a detailed understanding of the layers of abstraction in networking systems.)

HTTP actually lives at the highest layer of abstraction in networking, the application layer. Information that is sent by HTTP must meet certain requirements. In exchange for complying with the HTTP requirements, you gain the stability of a reliable interface, which is absolutely vital for transferring data to and from remote entities. A stable interface is a type of promise that you can always expect certain types of behavior.

But maybe you’re writing an application that doesn’t require that sort of universal promise — perhaps you’re writing micro-services, and you’re the client and the server, and able to make your own contracts. Maybe your application plans to rapidly deploy a large number of low-overhead listeners, perform tasks, and then tear them down. In that case, you might want more flexibility than HTTP offers, with slightly (TCP) or a lot (UDP) less overhead to the transaction process.

One major difference to get used to is that lower-level, socket-based services send binary messages, rather than string types. If you’ve not done that before, it’s a slight paradigm shift, but it will allow you to serialize your own custom types using Python’s pickle library and send them to your service. (This is possible in HTTP as well, but byte-encoding your information is more natural to the lower-level interface.)

What are the parts of a socket-based service?

There are a few parts to implementing a basic socket-based service.

  1. A listener or server
  2. A request handler
  3. Send/receive operations
  4. Encoding/decoding operations (for serialization)
  5. Client tools

Let’s walk through these.

A listener or server. This is a function or class that listens on some port for traffic coming from some range of IP addresses. It should be able to establish incoming connections and open a socket with requester. We’ll be using Python’s socketserver module for our implementation, which offers us several server implementations, simplifying our task greatly. (I’ll be using “listener” and “server” interchangeably here.) Listeners can listen indefinitely, or can be configured to shut down after handling a single request, or can even be configured to shut down on certain conditions. We’ll put it in a module (file) called server.py.

A request handler. This is a function or class that contains the logic for what to do with the connection to the client. Typically, this involves receiving information, doing something, and then sending a response — much like an HTTP service app would. We’ll put it in a module called handler.py.

Send/receive operations. These are functions that allow us to encapsulate the operations of sending and receiving messages so that we can use the same methods in both our server- and client-side code. We’ll put them in a module called send_rcv.py.

Encoding/decoding operations. These are functions that serialize and deserialize information so that it can be transmitted as bytes over the socket connection. Like the send/receive operations, we want these functions to be used by both client- and server-side code. We’ll put them in a module called encoding.py.

Client tools. These are functions or classes that provide a convenient interface to interact with our service. Providing client tools is really helpful to “your user” (understand: yourself, 2 months after writing your application).

If you’re reading this and using it as a tutorial, you might consider making a project folder that looks something like this:

my_sockserver_proj/
server.py
handler.py
send_rcv.py
encoding.py
client.py

What’s a basic implementation?

We’ll start with our request handler. If you’ve written HTTP service apps in frameworks like Flask or Django, you’ll be familiar with the general purpose of this function. We get a request from a client: what do we do with it?

I’ve provided a sample implementation below. (The following text expects that you will scroll down as necessary and glance at it, then scroll back up to continue reading.) The place where you provide some sort of useful behavior is where the print() call happens. This is where you inspect the_goods, query your data backend, etc.

Notice, however, that our data transfers have to be enacted before and after the core of the handling. The socket object that is created when a client connects to the server is available within the Handler class as self.request, which we alias as sock. Unlike a higher-level HTTP framework, with socketserver, you actually get your data by utilizing the socket object. We’ve encapsulated this “getting” behavior in the function recv_msg, shown below.

Also, you’ll note that data is transferred as bytes, not a string, requiring our encode and decode functions when we receive and send, respectively.

# handler.pyfrom socketserver import BaseRequestHandlerclass MyTCPHandler(BaseRequestHandler):

def handle(self):
sock = self.request

the_goods = decode(recv_msg(sock))
# Do something more interesting here:
print(f"Here's what we got: {the_goods}")
send_msg(sock, encode({"msg": "Goods received."}))

Now, let’s look at our server. If we use one of the implementations provided in the standard library, then the implementation is very straightforward. (See the ‘What else might I consider?’ section below for discussion of other standard library server implementations, including UDP and multi-threading.)

We’ve called the serve_forever() method, which will handle requests indefinitely until the process is terminated by some outside means. Another option that the standard library implementation offers is the handle_request() method, which terminates the server after handling a single request.

# server.pyimport socketserver
from handler import MyTCPHandler
with socketserver.TCPServer((host, port), MyTCPHandler) as server:
server.serve_forever()

Encoding. This, too, is straightforward. Here, we simply pickle. Note that this is a pretty good implementation, but production will require digging in and ensuring that all your types can be converted to a bytes-like object, and reverted from it. That’s why we abstract the implementation in these encode/ decode functions.

# encoding.pyimport pickledef encode(obj):
return pickle.dumps(obj)
def decode(obj):
return pickle.loads(obj)

Send/Receive. This part may contain some new concepts for you, depending on your programming I/O experience. Here’s the essential information: Data comes into the TCP socket as a stream, and you specify the chunksize you wish to read from that stream. I’ll try to break that down and flesh out how we use it to send and receive data. (Again, scroll down and see the code below, as it will be referenced througout this section.)

The basic way to get data that’s passed from the client is by calling the sock.recv(bytelength) method. Doing so will return a bytestringof length bytelength. If we knew the size of the message we were receiving, we could specify that size as the length and loop over the sock.recv method until we had all our data. In fact, that’s what we will do in this implementation, but we need a way of discovering the size of the information being sent.

So we implement a convention. For every message that is sent, a certain number of bytes at the beginning of the message hold a single integer that gives the number of bytes in the remainder of the message. This integer (represented in bytes) is prepended to the message’s byte string. If we specify in our code that the integer is 4 bytes long (a 32-bit int), then we can always perform 2 reads of incoming data: the first one reads the first 4 bytes to determine a length, msglen. The second reads msglen bytes and returns the entire message. The function recv_msg does this by making two calls to recvall.

Correspondingly, we need to ensure that when sending data, we prepend this information to the bytestring of the object. The function send_msg does this by a call to add_msg_header.

# send_rcv.pyBYTE_COUNT = 4def send_msg(sock, msg):
"""Send a message via the socket."""
msg = add_msg_header(msg)
sock.sendall(msg)
def recv_msg(sock):
"""Receive a message via the socket."""

# start by getting the header
# (which is an int of length `BYTE_COUNT`).
# The header tells the message size in bytes.
raw_msglen = recvall(sock, BYTE_COUNT)
if not raw_msglen:
return None
# Then retrieve a message of length `raw_msglen`
# this will be the actual message
msglen = len_frombytes(raw_msglen)
return recvall(sock, msglen)
def recvall(sock, length):
"""Get a message of a certain length from the socket stream"""
data = bytearray()
while len(data) < length:
packet = sock.recv(length - len(data))
if not packet:
return None
data.extend(packet)
return data
def add_msg_header(msg):
return len_inbytes(msg) + msg
def len_inbytes(msg):
return len(msg).to_bytes(BYTE_COUNT, byteorder='big')
def len_frombytes(bmsg):
return int.from_bytes(bmsg, byteorder='big')

Client tools. The following implementation encapsulates the process of creating a connection to the server, sending data, and receiving a response. It utilizes the encodingand send_rcv functions already encountered. Note that it’s synchronous. An async implementation is provided in the next section.

# client.pyimport time
import socket
from send_rcv import send_msg, recv_msg
from encoding import encode, decode
class MySynchronousTCPClient:
def __init__(self, host, port, timeout=10, wait=10):
self.addr = (host, port)
self.timeout = timeout
self.wait = wait
def send_request(self, the_goods):
t0 = time.time()
sock = socket.create_connection(
self.addr,
timeout=self.timeout
)

send_msg(encode(the_goods))
t0 = time.time()
msg = None
while not msg:
msg = decode(recv_msg(sock))
if time.time() - t0 > self.wait:
raise TimeoutError('Timed out waiting for resp.')

sock.close()
return msg

Using the client code. You’ve got the server running. Here’s how you might use the client code to send a very unexciting dictionary to the service. (We will assume that host and port are provided by a config object for now.)

host, port = config.host, config.portclient = MySynchronousTCPClient(host, port)data = {"type": "test_info", "body": {"msg": "This is a test."}}response = client.send_request(data)

Asynchronous Implementation

While the basic implementation is helpful for introducing the main concepts, for most use cases, you’re going to want an asynchronous implementation. Luckily, there are only a few modifications required to make an async implementation work. This is because the Python standard library asyncio module has socket functions that do a lot for us. We need to create a new client, and add a new function to our send/receive module.

Here’s the client:

# included in client.pyimport time
import asyncio
from send_rcv import recv_async, add_msg_header
from encoding import encode, decode
class MyAsyncTCPClient:
def __init__(self, host, port, timeout=10):
self.addr = (host, port)
self.timeout = timeout
def send_request(self, the_goods):
rcvr, sndr = asyncio.open_connection(
*self.addr,
ssl_handshake_timeout=self.timeout
)

sndr.write(add_msg_header(encode(the_goods)))
await sndr.drain()
data = await recv_async(rcvr)
received = decode(data)
sender.close()
await sender.wait_closed()
return received

And then we’ll need this additional function in send_rcv.py :

# included in send_rcvasync def recv_async(receiver):
raw_msglen = await receiver.read(BYTE_COUNT)
if not raw_msglen:
return None
msglen = len_frombytes(raw_msglen)
return await receiver.read(msglen)

The usage of the asyncio implementation requires your standard async behavior — you’ll need to await the method call and at some point gather your awaitables from the event loop. But a fuller introduction to asynchronous programming belongs in a different post.

What else might I consider?

A few things:

UDP. The previous examples are all for a TCP service. TCP offers guarantees about the transmission of your data — failures handled, packet order ensured — which are less relevant if the messages you send/receive from your service are very short. UDP does not offer these guarantees, which allows it operate with a much leaner profile. If overhead is a concern, your messages are short, and you’re willing to write fault-tolerant code, then the standard library offers UDP implementations for both server and connection type.

Threading and Forking. The standard library offers server implementations for multi-threaded or multi-process servers. See the socketserver official documentation for more information (including caveats).

Environment. The ability of your service to handle requests, particularly when it comes to deserializing objects, depends on the correspondence between the client environment and the server environment. If you’re not familiar with Python serialization, see the pickle module documentation.

Conclusion

You may find that your use cases are well-suited to socket-based service design. If so, you can greatly decrease your overhead (and possibly code burden) by creating a basic socketserver implementation that can be adapted to the various use cases within your distributed application design.

John is a software engineer who primarily works in data science platform design.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store