Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Share one HTTP client between workers

A client keeps finished connections in a pool for the next request to the same server. Every client method borrows the client shared, so jobs on an executor can use one client through Shared handles and reuse each other’s connections.

This program serves a loopback port from one thread and sends it requests from three pool jobs that share a client:

import std.http
import std.http.client
import std.http.server
import std.net
import std.time
from std.concurrent import ThreadPoolExecutor, Future, SubmitError, PoolError, FutureError
from std.http.client import Client, ClientError, Plain, TcpConnector
from std.http.server import Exchange
from std.net import TcpListener, TcpStream
from std.sync import CancellationToken

pub enum Route:
    Home

pub class Site:
    pub def handle(self, route: &Route, exchange: &mut Exchange[TcpStream]) -> Result[(), std.http.HttpError]:
        exchange.send_text(200, "home\n")?
        Ok(())

def serve(listener: TcpListener, stop: CancellationToken) -> Result[(), AllocError | std.http.server.ServerError]:
    mut routes = std.http.server.router[Route]()?
    routes.add("GET", "/", Route.Home)?
    drop(std.http.server.serve(listener, &routes, &Site(), &std.http.server.config(), &stop)?)
    Ok(())

def fetch_all(client: Shared[Client[Plain, TcpConnector]], url: str, count: i64) -> Result[i64, AllocError | ClientError | RangeError | OverflowError[i64]]:
    never = CancellationToken()?
    mut ok = 0
    for _ in range(count):
        deadline = std.time.deadline_after(&std.time.monotonic_now(), &std.time.seconds(5u32))?
        if client.get(&url, &deadline, &never)?.status == 200:
            ok += 1
    Ok(ok)

type PoolJobError = PoolError | FutureError | SubmitError[()]
type ClientRunError = OverflowError[i64] | OverflowError[u64] | AllocError | IoError | SpawnError[()] | RangeError | ClientError | std.net.NetError | std.http.server.ServerError | PoolJobError

def main() -> Result[(), ClientRunError]:
    listener = std.net.listen(&std.net.loopback_v4(0u16), 16)?
    port = listener.local_address()?.port
    stop = CancellationToken()?
    with spawn(serve, listener, stop.share()).drop_job()? as server, stop.cancel_on_exit():
        url = f"http://127.0.0.1:{port}/"?
        client = Shared(std.http.client.client(std.http.client.config()?)?)?
        mut answered = 0
        with ThreadPoolExecutor(3, 8)? as pool:
            a = pool.submit(fetch_all, client.share(), url, 10).drop_job()?
            b = pool.submit(fetch_all, client.share(), url, 10).drop_job()?
            c = pool.submit(fetch_all, client.share(), url, 10).drop_job()?
            answered = a.result()?? + b.result()?? + c.result()??
        stats = client.stats()
        print(answered, stats.opened + stats.reused, stats.opened <= 3)?
        print(stats.opened == u64(client.idle()) + stats.discarded)?
        stop.cancel()
        server.join()?
    Ok(())
30 30 True
True

stop.cancel_on_exit() stops the server however the block ends, so a failed request cannot leave the block’s join waiting for it. On success, stop.cancel() ends the server before server.join()? checks its result.

Shared(client(config)?)? moves the client into one shared allocation; client.share() gives each job its own owner, and client.get reaches the client through the handle. Thirty requests went out, and every one either opened a connection or reused one: opened + reused is 30. A job holds one connection at a time and opens one only when none is idle, and a connection one job parks is taken by whichever job asks next. Three jobs ran at once and none of their connections closed, so at most three were opened.

The client holds its pool lock only while taking a connection out or putting one back. Connecting, sending, and waiting for a response happen with the lock released, so a job stuck on a slow server does not delay the others. Each request still has its own deadline and cancellation token.

stats() and idle() work through the handle too. These observation counters saturate at u64.MAX; the small fixed workload here stays far below that limit. Before saturation, every connection the client opened is idle, in use, or counted as discarded once it has closed, whatever closed it; with no request or response holding one, opened is idle() plus discarded, as the last line shows. The pool limits max_idle and max_idle_per_origin and the idle_timeout apply to the shared pool as a whole; see the client reference.