Network Backends

The lowest level network abstractions in httpx are the NetworkBackend and NetworkStream classes. These provide a consistent interface onto the operations for working with a network stream, typically over a TCP connection. Different runtimes (threaded, trio & asyncio) are supported via alternative implementations of the core interface.


NetworkBackend()

The default backend is instantiated via the NetworkBackend class...

httpx
>>> net = httpx.NetworkBackend()
>>> net
<NetworkBackend [threaded]>

.connect(host, port)

A TCP stream is created using the connect method...

httpx
>>> net = httpx.NetworkBackend()
>>> stream = net.connect("www.encode.io", 80)
>>> stream
<NetworkStream ["168.0.0.1:80"]>

Streams support being used in a context managed style. The cleanest approach to resource management is to use .connect(...) in the context of a with block.

httpx
>>> net = httpx.NetworkBackend()
>>> with net.connect("dev.encode.io", 80) as stream:
>>>     ...
>>> stream
<NetworkStream ["168.0.0.1:80" CLOSED]>

NetworkStream(sock)

The NetworkStream class provides TCP stream abstraction, by providing a thin wrapper around a socket instance.

Network streams do not provide any built-in thread or task locking. Within httpx thread and task saftey is handled at the Connection layer.

.read(max_bytes=None)

Read up to max_bytes bytes of data from the network stream. If no limit is provided a default value of 64KB will be used.

.write(data)

Write the given bytes of data to the network stream.

.start_tls(ctx, hostname)

Upgrade a stream to TLS (SSL) connection for sending secure https:// requests.

<NetworkStream [“168.0.0.1:443” TLS]>

.get_extra_info(key)

Return information about the underlying resource. May include...

.close()

Close the network stream. For TLS streams this will attempt to send a closing handshake before terminating the conmection.

httpx
>>> net = httpx.NetworkBackend()
>>> stream = net.connect("dev.encode.io", 80)
>>> try:
>>>     ...
>>> finally:
>>>     stream.close()
>>> stream
<NetworkStream ["168.0.0.1:80" CLOSED]>

Timeouts

Network timeouts are handled using a context block API.

This design approach avoids timeouts needing to passed around throughout the stack, and provides an obvious and natural API to dealing with timeout contexts.

timeout(duration)

The timeout context manager can be used to wrap socket operations anywhere in the stack.

Here's an example of enforcing an overall 3 second timeout on a request.

httpx
>>> with httpx.Client() as cli:
>>>     with httpx.timeout(3.0):
>>>         res = cli.get('https://www.example.com')
>>>         print(res)

Timeout contexts provide an API allowing for deadlines to be cancelled.

.cancel()

In this example we enforce a 3 second timeout on receiving the start of a streaming HTTP response...

httpx
>>> with httpx.Client() as cli:
>>>     with httpx.timeout(3.0) as t:
>>>         with cli.stream('https://www.example.com') as r:
>>>             t.cancel()
>>>             print(">>>", res)
>>>             for chunk in r.stream:
>>>                 print("...", chunk)

Sending HTTP requests

Let's take a look at how we can work directly with a network backend to send an HTTP request, and recieve an HTTP response.

httpx
import httpx
import ssl
import truststore

net = httpx.NetworkBackend()
ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
req = b'\r\n'.join([
    b'GET / HTTP/1.1',
    b'Host: www.example.com',
    b'User-Agent: python/dev',
    b'Connection: close',
    b'',
    b'',
])

# Use a 10 second overall timeout for the entire request/response.
with httpx.timeout(10.0):
    # Use a 3 second timeout for the initial connection.
    with httpx.timeout(3.0) as t:
        # Open the connection & establish SSL.
        with net.connect("www.example.com", 443) as stream:
            stream.start_tls(ctx, hostname="www.example.com")
            t.cancel()
            # Send the request & read the response.
            stream.write(req)
            buffer = []
            while part := stream.read():
                buffer.append(part)
            resp = b''.join(buffer)

The example above is somewhat contrived, there's no HTTP parsing implemented so we can't actually determine when the response is complete. We're using a Connection: close header to request that the server close the connection once the response is complete.

A more complete example would require proper HTTP parsing. The Connection class implements an HTTP request/response interface, layered over a NetworkStream.


Custom network backends

The interface for implementing custom network backends is provided by two classes...

NetworkBackendInterface

The abstract interface implemented by NetworkBackend. See above for details.

NetworkStreamInterface

The abstract interface implemented by NetworkStream. See above for details.

An example backend

We can use these interfaces to implement custom functionality. For example, here we're providing a network backend that logs all the ingoing and outgoing bytes.

httpx
class RecordingBackend(httpx.NetworkBackendInterface):
    def __init__(self):
        self._backend = NetworkBackend()

    def connect(self, host, port):
        # Delegate creating connections to the default
        # network backend, and return a wrapped stream.
        stream = self._backend.connect(host, port)
        return RecordingStream(stream)


class RecordingStream(httpx.NetworkStreamInterface):
    def __init__(self, stream):
        self._stream = stream

    def read(self, max_bytes: int = None):
        # Print all incoming data to the terminal.
        data = self._stream.read(max_bytes)
        lines = data.decode('ascii', errors='replace').splitlines()
        for line in lines:
            print("<<< ", line)
        return data

    def write(self, data):
        # Print all outgoing data to the terminal.
        lines = data.decode('ascii', errors='replace').splitlines()
        for line in lines:
            print(">>> ", line)
        self._stream.write(data)

    def start_tls(ctx, hostname):
        self._stream.start_tls(ctx, hostname)

    def get_extra_info(key):
        return self._stream.get_extra_info(key)

    def close():
        self._stream.close()

We can now instantiate a client using this network backend.

httpx
>>> transport = httpx.ConnectionPool(backend=RecordingBackend())
>>> cli = httpx.Client(transport=transport)
>>> cli.get('https://www.example.com')

Custom network backends can also be used to provide functionality such as handling DNS caching for name lookups, or connecting via a UNIX domain socket instead of a TCP connection.


Connections