Skip to content

without_http

A sans-IO-backed ASGI server and HTTP client for without: h11/h2/wsproto over asyncio sockets.

without_http

ALPN_PROTOCOLS module-attribute

ALPN_PROTOCOLS = ('h2', 'http/1.1')

ClientExchange

ClientMiddleware

ClientMiddleware = Endo[ClientExchange]

SocketOptions

SocketOptions = tuple[tuple[int, int, int], ...]

ClientRequest dataclass

ClientRequest(
    method: str,
    url: str,
    headers: RawHeaders = (),
    body: Stream[bytes] = _empty_body(),
)

A client request as a value: the head plus a streaming body.

The body is a Stream[bytes] (an async iterable of chunks), so a request can be buffered (one chunk) or streamed (many), the upload half of the buffered/streaming matrix. Because the whole request is the value a ClientExchange transforms, middleware can rewrite it: add headers, change the URL, wrap the body.

method instance-attribute

method: str

url instance-attribute

url: str

headers class-attribute instance-attribute

headers: RawHeaders = ()

body class-attribute instance-attribute

body: Stream[bytes] = field(default_factory=_empty_body)

ClientResponse

Bases: NamedTuple

A client response as a value: the head paired with the body.

head is the parsed ResponseHead (status + headers), available the instant await exchange(request) returns. body is a ResponseBody, a once-consumable stream that releases its connection when it ends or is closed.

A NamedTuple so a caller can take it whole (response.head, response.body) or unpack it (head, body = response) with each field keeping its precise type, which a __iter__ on a dataclass could not give. The two halves are independent, the consumer split that mirrors how a server consumes a request (a scope value plus a body stream): branch on head without touching body. ConnectionPool.request yields this value and closes body on exit; it is also what a ClientExchange rewrites (by constructing a new one, since a NamedTuple has no dataclasses.replace).

head instance-attribute

body instance-attribute

ConnectionPool dataclass

ConnectionPool(
    allow_http2: bool = True,
    force_http2_cleartext: bool = False,
    ssl_context_factory: Callable[
        [], SSLContext
    ] = create_default_context,
    middleware: ClientMiddleware = _PASSTHROUGH,
    max_connections_per_host: int | None = None,
    max_keepalive_per_host: int | None = None,
    timeout: Timeout = _NO_TIMEOUT,
    socket_options: SocketOptions = _DEFAULT_SOCKET_OPTIONS,
    _h2: dict[Origin, _Http2Connection] = dict(),
    _h11: dict[Origin, _HostPool] = dict(),
    _h11_only: set[Origin] = set(),
    _contexts: dict[tuple[str, ...], SSLContext] = dict(),
    _origin_locks: dict[Origin, Lock] = dict(),
)

Connections keyed by origin, and the entrypoint for making requests.

Open it as an async context manager (async with ConnectionPool(...) as pool) so its connections are closed on exit; a directly-constructed pool works for short-lived use but does not manage the long-lived connections keep-alive retains. Make requests through async with pool.request(...) as response.

HTTP/2 connections are kept and reused: many concurrent requests to one origin multiplex over a single connection, which is the point of h2. HTTP/1.1 connections are kept too but used serially: an idle one is checked out for a request and returned once its response body is read (keep-alive), so a fresh one is opened only when none is idle. An h2 connection is negotiated over TLS by ALPN when allow_http2 is set (the default; it is allowed, falling back to HTTP/1.1 if the server does not offer it), or over cleartext by prior knowledge when force_http2_cleartext is set (the caller asserting the server speaks h2c, since cleartext cannot negotiate); otherwise the origin speaks HTTP/1.1.

ssl_context_factory produces the TLS client context (default ssl.create_default_context). The pool calls it to build the contexts it opens with and sets ALPN on them itself, holding one per distinct offer, so it never mutates (nor shares the ALPN of) a context the caller holds. Pass a factory, not a live context, precisely because ALPN can only be set context-wide: a shared context would be mutated out from under other pools or libraries using it.

middleware decorates every request through the pool; per-request middleware on request composes inside it. Keep pool-level middleware to pure decoration (default headers, redirect following, retry): things that are values, not state. Anything carrying mutable, request-spanning identity (a CookieJar, an auth session) belongs in a value you own and pass per request, so that connection reuse (a transport concern) and application identity stay independent rather than both hiding in the pool.

max_connections_per_host bounds the number of concurrent HTTP/1.1 connections to one origin: at the bound, a checkout waits for one to be returned (the wait a pool timeout guards). It is unbounded by default, mirroring the server's choice to let OS backpressure cap connections rather than an in-process limit; opt into a bound when a caller wants explicit per-host backpressure.

max_keepalive_per_host bounds a different axis: how many idle HTTP/1.1 connections are retained per origin once a burst subsides. At the cap, a returned connection is closed instead of pooled, so the pool ramps up to max_connections_per_host under concurrent load but settles back down to max_keepalive_per_host when quiet rather than holding every socket open. It is unbounded by default (every reusable connection is kept); a value above max_connections_per_host is never reached, since idle connections cannot outnumber concurrent checkouts. Both knobs, when set, MUST be >= 1.

timeout bounds each phase of a request (see Timeout); it defaults to no timeouts, since a deadline is the caller's policy, not the transport's. A per-request timeout on request replaces it wholesale for that call.

socket_options is applied to every socket the pool opens, as (level, option, value) triples. Build it by concatenating the pure producers in without_http.socket_options (tcp_keepalive, send_buffer_size, ...), the way headers concatenate: each describes one concern, and the pool hands the combined set to setsockopt in order. It defaults to tcp_keepalive(), so a pooled connection is probed for a peer that vanished silently; pass () for the kernel's defaults, or include tcp_keepalive(...) in a longer set to keep probing while setting more.

allow_http2 class-attribute instance-attribute

allow_http2: bool = True

force_http2_cleartext class-attribute instance-attribute

force_http2_cleartext: bool = False

ssl_context_factory class-attribute instance-attribute

ssl_context_factory: Callable[[], SSLContext] = (
    ssl.create_default_context
)

middleware class-attribute instance-attribute

middleware: ClientMiddleware = _PASSTHROUGH

max_connections_per_host class-attribute instance-attribute

max_connections_per_host: int | None = None

max_keepalive_per_host class-attribute instance-attribute

max_keepalive_per_host: int | None = None

timeout class-attribute instance-attribute

timeout: Timeout = _NO_TIMEOUT

socket_options class-attribute instance-attribute

socket_options: SocketOptions = _DEFAULT_SOCKET_OPTIONS

request async

request(
    method: str,
    url: str,
    *,
    headers: RawHeaders = (),
    body: bytes | Stream[bytes] = b"",
    middleware: ClientMiddleware = _PASSTHROUGH,
    timeout: Timeout | None = None,
) -> AsyncIterator[ClientResponse]

Send a request and yield its ClientResponse for the block, then release the connection.

body is the request body: bytes to buffer it, or a Stream[bytes] to stream it. The yielded ClientResponse can be taken whole (response.head, response.body) or unpacked (head, body = ...); read the response body with async for chunk in body / await body.read(), or body.read_with_trailers() when the endpoint carries trailers. On exit any unread body is drained or aborted so the connection is never stranded.

middleware is composed inside the pool's own middleware, so a single request can add decoration (a CookieJar via cookies, an extra header) on top of the pool-wide stack for this call alone.

timeout replaces the pool's own timeout for this call (None inherits it). It is captured as a value in the transport exchange, so it does not compose through middleware the way decoration does; a deadline overrides, it does not layer.

aclose async

aclose() -> None

CookieJar dataclass

CookieJar(
    _cookies: dict[tuple[str, str, str], _Cookie] = dict(),
    _now: Callable[[], datetime] = _utcnow,
)

A mutable cookie store you construct and hand to the cookies middleware.

Deliberately not owned by a ConnectionPool: cookie scope (application identity) and connection reuse (transport) are independent, so binding them the way a single client object would is a needless coupling. Construct a jar, pass it to cookies, and which requests share it decides what shares cookies, one jar per logical user session regardless of how connections are pooled.

Supports host-only and Domain (subdomain) matching, Path matching, the Secure attribute, and expiry via Max-Age<=0 or a past Expires. A Set-Cookie whose Domain the response host is not allowed to set, or a Secure cookie offered over a cleartext response, is rejected at store time. _now supplies the clock for expiry (injectable for tests). Not yet: full public-suffix-list rejection of Domain values like co.uk, and forward expiry of a positive Max-Age.

Two ways in: store, which parses Set-Cookie off an (untrusted) response and so applies the origin guards above; and add, which places a hand-written cookie the caller vouches for, skipping those guards.

add

add(
    name: str,
    value: str,
    *,
    domain: str,
    path: str = "/",
    secure: bool = False,
    subdomains: bool = False,
    expires: datetime | None = None,
) -> None

Add a hand-written cookie directly, without a Set-Cookie response.

The trusted counterpart to store: because the caller vouches for the cookie (a session token already in hand, a test fixture), the origin checks store applies to an untrusted response, the Domain scope check and the Secure-over-cleartext rejection, do not apply here. subdomains=True sends it to domain and its subdomains; the default sends it only to the exact domain host. An entry with the same (domain, path, name) identity is replaced.

store

store(url: str, headers: RawHeaders) -> None

Fold every Set-Cookie in a response into the jar.

header_for

header_for(url: str) -> bytes | None

The Cookie header value for a request to url, or None if none match.

ResponseBody dataclass

ResponseBody(
    _events: AsyncGenerator[bytes | ResponseTrailers],
)

A response body: a stream of bytes chunks, optionally ended by trailers.

Consumed exactly once, by one of four methods spanning two axes, stream vs buffer and drop-trailers vs keep-trailers:

  • async for chunk in body / await body.read() yield bytes, dropping any trailers, so the common path pays nothing for a feature it does not use.
  • body.events() / await body.read_with_trailers() keep trailers, surfaced as ResponseTrailers after the byte chunks. Reach for these only when you know (out of band) the endpoint uses trailers; read_with_trailers returns all trailer blocks (an empty tuple if none), so a consumer that requires them enforces that.

Dropping trailers still drains the stream to its end, so the connection releases as fully-read (see _with_release): it filters the terminal, it does not stop early.

read async

read() -> bytes

events

read_with_trailers async

read_with_trailers() -> tuple[
    bytes, tuple[ResponseTrailers, ...]
]

aclose async

aclose() -> None

ResponseHead dataclass

ResponseHead(status: int, headers: RawHeaders)

A response's head as the client parses it off the wire: status plus headers.

Without-http's inbound counterpart to without-asgi's outbound ResponseStart. Same fields, deliberately not the same type: an outbound type carries defaults so an app constructs it ergonomically, but a parsed-from-the-wire type must have no defaults, so a field the parser forgot fails loudly instead of silently defaulting (the inbound/outbound rule, mirroring without-asgi's RequestBody vs ResponseBody).

status instance-attribute

status: int

headers instance-attribute

headers: RawHeaders

ResponseTrailers dataclass

ResponseTrailers(headers: RawHeaders)

A trailing header block, parsed off the wire after the response body.

The inbound counterpart to without-asgi's outbound ResponseTrailers, with no defaults for the same reason as ResponseHead. A response is modeled as carrying zero or more such blocks at the tail of its body stream, so consumers see them as a sequence.

headers instance-attribute

headers: RawHeaders

LifespanError

Bases: Exception

The application reported a lifespan startup or shutdown failure.

Server dataclass

Server(
    host: str, port: int, _connections: _LiveConnections
)

A handle to a running server, yielded by serving for the block's duration.

host/port are the bound address (port is the OS-assigned one when port=0 was requested). in_flight reports how many connections are being served right now, for metrics and observability. More fields (request counts, byte totals) can join as the server grows.

host instance-attribute

host: str

port instance-attribute

port: int

in_flight property

in_flight: int

ConnectTimeout

Bases: HTTPTimeout

Establishing the connection to the origin took too long.

The request never reached the server, so it is always safe to retry, even a non-idempotent one, or to fail over to another origin.

HTTPTimeout

Bases: TimeoutError

Base for a phase-specific client timeout, subclassing TimeoutError.

A coarse except TimeoutError catches any of them; the specific type tells the caller how far the request got, which is what determines the safe recovery (the reason a typed per-phase error beats a bare TimeoutError). See each subclass.

PoolTimeout

Bases: HTTPTimeout

Acquiring a connection slot took too long (the per-host bound or h2 stream limit).

The request never left the process, so it is always safe to retry, though the real fix is usually local backpressure rather than retrying the peer.

ReadTimeout

Bases: HTTPTimeout

Waiting for the next chunk of the response took too long.

The request was fully sent, so the server may already have processed it: retry only if the request is idempotent or carries an idempotency key, otherwise surface it. If it fired mid-body, the partial response already read is yours to keep or discard.

Timeout dataclass

Timeout(
    connect: timedelta | None = None,
    read: timedelta | None = None,
    write: timedelta | None = None,
    pool: timedelta | None = None,
)

Per-phase inactivity bounds for a client request, each disabled (None) by default.

Four axes, following httpx, each bounding one phase of a request that fails for its own reason (see the without-http guide's request-lifecycle table):

  • connect: establishing the TCP (and TLS) connection to the origin.
  • read: waiting for the next chunk of the response (re-armed per chunk, so it bounds the gap between chunks, not the whole read).
  • write: making progress sending the next chunk of the request body (re-armed per write, so a lazily-fed body that pauses between chunks does not trip it).
  • pool: waiting to acquire a connection slot, which only bites once a per-host bound (or the h2 stream limit) is in force.

Each axis is a timedelta, so the unit is explicit at the call site rather than an ambiguous bare number. Every field defaults to None (that axis disabled), so the default Timeout() bounds nothing: a timeout is a policy keyed to the caller's time budget ("fail rather than make slow progress so my upstream can react"), which the transport cannot know, so a caller opts in per axis (Timeout(connect=timedelta( seconds=10), read=timedelta(seconds=30))). There is deliberately no shared-default scalar: one duration across four unrelated phases carries no meaning. For an overall wall-clock cap, compose async with asyncio.timeout(t): pool.request(...).

Each axis is applied through its own bound: connecting(), reading(), writing(), and pooling() each return a context manager that bounds the wrapped await(s) by that axis and raises its typed error on expiry. The mapping from axis to typed error lives here, once, rather than at every call site that arms a deadline.

connect class-attribute instance-attribute

connect: timedelta | None = None

read class-attribute instance-attribute

read: timedelta | None = None

write class-attribute instance-attribute

write: timedelta | None = None

pool class-attribute instance-attribute

pool: timedelta | None = None

connecting

connecting() -> AbstractAsyncContextManager[None]

Bound establishing the connection, raising ConnectTimeout on expiry.

reading

reading() -> AbstractAsyncContextManager[None]

Bound awaiting the next response chunk, raising ReadTimeout on expiry.

writing

writing() -> AbstractAsyncContextManager[None]

Bound making progress on the request body, raising WriteTimeout on expiry.

pooling

pooling() -> AbstractAsyncContextManager[None]

Bound acquiring a connection slot, raising PoolTimeout on expiry.

WriteTimeout

Bases: HTTPTimeout

Making progress sending the request body took too long.

The server saw at most a partial request. Retrying is safe for an idempotent request and ambiguous otherwise; the connection is discarded, so a retry gets a fresh one.

add_headers

add_headers(
    *headers: tuple[bytes, bytes],
) -> ClientMiddleware

Client middleware that adds headers to every request.

The mirror of a server's request-decorating middleware: it sits in the same stack and rewrites the request before the inner exchange runs. This is how a pool sends default headers (auth tokens, a user agent) on every request, or a single request adds its own.

cookies

cookies(jar: CookieJar) -> ClientMiddleware

Client middleware that carries cookies through a CookieJar you own.

Reads Set-Cookie off each response into jar and writes the matching Cookie header onto each outgoing request. This is the stateful counterpart to add_headers: its mutable jar is passed in explicitly rather than hidden in the pool, so two requests share cookies exactly when they share a jar.

Place it inside follow_redirects in a stack (stack(follow_redirects(), cookies(jar))) so each redirect hop both sends the jar's cookies and collects any the hop sets.

follow_redirects

follow_redirects(max_hops: int = 5) -> ClientMiddleware

Client middleware that follows 3xx redirects, up to max_hops.

Each intermediate response is drained before the next hop, so its connection is released. The follow re-issues the same request body, so redirects with a one-shot streaming body are not replayable; in practice redirects follow bodyless requests.

Credentials are not replayed across an origin boundary: when a hop's target has a different scheme, host, or port, Authorization, Cookie, and Proxy-Authorization are dropped before the request is re-issued. A hop that would downgrade https to http is refused outright (the 3xx is returned unfollowed) so nothing is replayed over cleartext.

stack

stack(
    *middleware: Callable[[H, *Ctx], H],
) -> Callable[[H, *Ctx], H]

Compose middleware into one, first argument outermost; stack() is identity.

A middleware is (handler, *context) -> handler: it wraps a handler, given some fixed context, into a new handler of the same type. The context is whatever the setting threads through unchanged: nothing for a client exchange (Endo[H]), the connection state and scope for a server handler. stack threads the same context into every middleware and chains the handler through them, first outermost, so stack(f, g)(handler, *context) is f(g(handler, *context), *context).

Generic over the handler H (the value each middleware transforms) and the context pack *Ctx, which is bound once per call: every middleware in one stack(...) must therefore share a shape, and mixing shapes is a type error. The pack passes through untouched (never wrapped element-wise), which is exactly why one variadic generic covers every arity here where a heterogeneous ladder would be needed.

wrap

wrap(
    *,
    request: Endo[ClientRequest] | None = None,
    response: Endo[ClientResponse] | None = None,
) -> ClientMiddleware

Build a ClientMiddleware from a request and/or response transform.

The client counterpart to without-asgi's wrap: where the server wraps a handler's inbound/outbound streams, the client wraps an exchange's request (before it is sent) and response (after it returns). request rewrites the outgoing ClientRequest (headers, URL, body); response transforms the returned ClientResponse (e.g. wrapping its body). Either omitted leaves that side untouched.

This is the easy path for the independent before/after case (the dual of why add_headers, below, is a one-liner over it). A middleware whose two sides share state, like cookies needing the request URL when it stores the response, or that loops, like follow_redirects, is written directly as a ClientExchange wrapper.

early_hint_headers

early_hint_headers(
    links: Iterable[bytes],
) -> list[tuple[bytes, bytes]]

Render a 103 Early Hints informational response as an h2 header block.

request_headers

request_headers(
    method: bytes,
    target: bytes,
    scheme: str,
    authority: bytes,
    headers: RawHeaders,
) -> list[tuple[bytes, bytes]]

Render a client request as the h2 header block: the pseudo-headers, then the rest.

The dual of scope_from_h2_headers: the request line and Host become the :method/:path/:scheme/:authority pseudo-headers (h2 carries the host as :authority, never an ordinary host header). Names are lowercased and the hop-by-hop headers illegal over h2 are dropped, so a request written for HTTP/1.1 round-trips over HTTP/2 without tripping hpack.

response_headers

response_headers(
    status: int, headers: RawHeaders
) -> list[tuple[bytes, bytes]]

Render a response start as the h2 header block: :status first, then the rest.

Header names are lowercased (HTTP/2 requires it) and the hop-by-hop headers that are illegal over h2 are dropped, so a response written for HTTP/1.1 round-trips over HTTP/2 without tripping hpack.

response_status_and_headers

response_status_and_headers(
    headers: Iterable[tuple[bytes, bytes]],
) -> tuple[int, RawHeaders]

Read an h2 response header block back into a status and ordinary headers.

The dual of response_headers: the :status pseudo-header becomes the numeric status and every other pseudo-header is dropped, leaving the ordinary response headers the client surfaces.

scope_from_h2_headers

scope_from_h2_headers(
    headers: Iterable[tuple[bytes, bytes]],
    *,
    scheme: str,
    server: tuple[str, int | None] | None,
    client: tuple[str, int] | None,
) -> HttpScope

Build the typed HttpScope an ASGI app expects from an h2 request's headers.

Pure: it reads only the request pseudo-headers (:method/:path/:authority) and the connection facts the transport already knows (peer addresses, scheme). The scheme is taken from the transport, not the client-asserted :scheme. The :authority is folded into a synthesized host header when the request carries none, the same mapping uvicorn makes for HTTP/2.

h11_events_from_outbound

h11_events_from_outbound(outbound: Outbound) -> list[Event]

Render one typed Outbound as the h11 events that put it on the wire.

HTTP/1.1 carries the response start, body, and 103 early hints. The server-offload and HTTP/2-only extensions (server push, zero-copy/path send, trailers, debug) have no HTTP/1.1 representation and the transport never advertised them, so reaching one is a programming error, not a wire case.

inbound_from_event

inbound_from_event(event: Event) -> Inbound | None

Classify one body-phase h11 event as a typed Inbound, or None to skip.

h11.Data is a body chunk (more to come); h11.EndOfMessage is the final, empty chunk that closes the request body; h11.ConnectionClosed is the client going away. Any other event is not part of the request body and is skipped.

scope_from_request

scope_from_request(
    request: Request,
    *,
    scheme: str,
    server: tuple[str, int | None] | None,
    client: tuple[str, int] | None,
) -> HttpScope

Build the typed HttpScope an ASGI app expects from an h11.Request.

Pure: it reads only the request event and the connection facts the transport already knows (peer addresses, scheme). The ASGI path is the percent-decoded target; raw_path keeps the bytes as received, the same split uvicorn makes.

run_lifespan async

run_lifespan(app: ASGIApp) -> AsyncIterator[None]

Drive the ASGI lifespan protocol around an app for the server's lifetime.

Runs the app once with a lifespan scope as a background task: sends lifespan.startup on entry and waits for the app to ack, then lifespan.shutdown on exit. A startup/shutdown the app reports as failed raises LifespanError.

An app that does not support lifespan signals so by raising before it acks startup; that is not an error, so the server continues without a lifespan cycle. This is the standard ASGI server fallback.

serving async

serving(
    app: ASGIApp,
    *,
    host: str = "127.0.0.1",
    port: int = 0,
    max_pending_connections: int = 100,
    max_concurrent_streams: int = 100,
    max_stream_resets: int = 200,
    idle_timeout: timedelta | None = None,
    max_websocket_message_bytes: int | None = None,
    ssl_context: SSLContext | None = None,
    ssl_handshake_timeout: float | None = None,
    ssl_shutdown_timeout: float | None = None,
    socket_options: SocketOptions = (),
) -> AsyncIterator[Server]

Serve app over HTTP for the duration of the with block.

Drives the lifespan cycle, binds a socket (port=0 picks a free one) with asyncio.start_server, and yields a Server (its bound host/port, plus live metrics like in_flight). On exit it stops accepting, cancels any in-flight connections, and runs lifespan shutdown. To run a server until cancelled, hold the block open with without.sleep_forever() (or your own run loop, e.g. one that handles signals).

max_pending_connections is the kernel's listen backlog: the depth of the queue of connections that have completed the TCP handshake but have not yet been accepted. It absorbs accept bursts; once a connection is accepted it no longer counts against it. When the queue is full, the OS handles further connection attempts: on Linux the new SYN is dropped, so the client's connect() retransmits and either succeeds once room frees or eventually times out (a client may also see "connection refused" on platforms that reset instead). Nothing is queued in the server process.

To bound in-flight requests (the right limit once HTTP/2 multiplexes many requests over one connection), wrap the app in limit_concurrent_requests, which sheds with a 503 rather than capping connections. This server does not cap raw connections: the kernel listen backlog above and OS resource limits provide that backpressure, and Server.in_flight reports the live connection count for metrics.

asyncio.start_server owns the accept loop, so it survives transient accept failures (pausing for ACCEPT_RETRY_DELAY on resource exhaustion) and binds every address host resolves to.

Four per-connection resource bounds harden a public deployment. idle_timeout (a timedelta, off by default) closes a connection whose peer stops sending data mid-exchange, defeating a slowloris; it also bounds an idle WebSocket. Over HTTP/2, max_concurrent_streams is advertised as MAX_CONCURRENT_STREAMS and max_stream_resets caps how many stream resets one connection may issue before it is dropped, together defeating the Rapid Reset flood (CVE-2023-44487). max_websocket_message_bytes (off by default) caps a reassembled WebSocket message. Tune them at your composition root, e.g. from a settings value parsed by without_env.EnvContext, rather than reaching for the environment here.

Pass ssl_context to serve https/wss directly; server_ssl_context builds one for the common case. ssl_handshake_timeout bounds a single TLS handshake (asyncio's default is 60s) and ssl_shutdown_timeout the closing close_notify exchange (default 30s); both are meaningful only alongside ssl_context.

socket_options is applied to the listening socket, as (level, option, value) triples built by concatenating the pure producers in without_http.socket_options (receive_buffer_size, ...), the same way the client pool takes them. The kernel hands a listening socket's buffer sizes down to every connection accepted on it, so receive_buffer_size here bounds what the server will buffer from a peer whose body it has not read yet. Options that only make sense per-connection have nothing to act on at bind time; the default (()) leaves the kernel's own choices alone.

receive_buffer_size

receive_buffer_size(size: int) -> SocketOptions

Pin the socket's receive buffer to size bytes (SO_RCVBUF).

The receive-side counterpart of send_buffer_size, with the same caveats and the same guarantee: autotuning is bounded by net.ipv4.tcp_rmem, whose docs say "Calling setsockopt() with SO_RCVBUF disables automatic tuning of that socket's receive buffer size, in which case this value is ignored". The cap is net.core.rmem_max and the stored value is likewise doubled.

Set on a listening socket, it is inherited by every accepted connection, which is how a server bounds what it will buffer from a peer whose body it has not read yet.

send_buffer_size

send_buffer_size(size: int) -> SocketOptions

Pin the socket's send buffer to size bytes (SO_SNDBUF).

Pinning it is what makes the buffer a known size. Left alone, Linux autotunes the send buffer up to the max of the net.ipv4.tcp_wmem sysctl, and that sysctl's own documentation is the guarantee relied on here: "Calling setsockopt() with SO_SNDBUF disables automatic tuning of that socket's send buffer size, in which case this value is ignored."

Two caveats from socket(7), which make this a bound rather than an exact reservation: the value is capped at net.core.wmem_max, and "the kernel doubles this value (to allow space for bookkeeping overhead) when it is set using setsockopt(2), and this doubled value is returned by getsockopt(2)", so reading it back does not return what was set.

The sysctl names above are Linux's. SO_SNDBUF itself is POSIX and portable; what varies elsewhere is only which knob bounds it.

tcp_keepalive

tcp_keepalive(
    *,
    idle: timedelta = timedelta(seconds=60),
    interval: timedelta = timedelta(seconds=10),
    count: int = 6,
) -> SocketOptions

Enable TCP keepalive and tune its probe timing.

Once enabled, the OS probes an otherwise-idle connection and tears it down if the peer has gone away without a TCP FIN: a crashed server, a network partition, a NAT or firewall silently dropping the flow. A clean server-side keep-alive close sends a FIN, which the pool already notices before reuse (_Http11Connection.usable); keepalive covers the silent case, which matters most when request timeouts are disabled (the default), since nothing else would notice a dead idle socket until a request hung on it.

  • idle: how long a connection sits idle before the first probe.
  • interval: the gap between probes once they start.
  • count: unanswered probes before the connection is declared dead.

So a broken idle connection is dropped roughly idle + interval * count after it goes quiet. idle/interval MUST be a whole number of seconds, since the OS options carry only integer seconds (a sub-second component is rejected here rather than silently truncated); count is a plain probe count, not a duration.

Enabling keepalive (SO_KEEPALIVE) is portable, but the per-probe tuning is not uniformly spelled or present, so the result includes each knob only where the running platform exposes it and omits the rest (leaving that axis at the OS default):

  • Linux: idle/interval/count are TCP_KEEPIDLE/TCP_KEEPINTVL/TCP_KEEPCNT.
  • macOS: there is no TCP_KEEPIDLE; the idle knob is TCP_KEEPALIVE (used the same way), with TCP_KEEPINTVL/TCP_KEEPCNT for the other two.
  • Windows: the same three names as Linux, but only on recent builds (added "when available", Windows 10+); older Windows exposes none of them, so only SO_KEEPALIVE is enabled and the probe timing stays at the system default.

server_ssl_context

server_ssl_context(
    certfile: Path, keyfile: Path | None = None
) -> SSLContext

Build a server-side TLS context that serves the protocols without-http speaks.

Loads the certificate chain (a combined cert+key PEM if keyfile is omitted) and advertises ALPN_PROTOCOLS, so a client negotiates the wire protocol during the handshake. Pass the result to serving/serve as ssl_context to serve https (and wss) directly.

This is a convenience for the common case. A caller needing more control (an encrypted key, client-certificate verification, a custom cipher suite) builds its own ssl.SSLContext and passes that instead.

is_websocket_upgrade

is_websocket_upgrade(request: Request) -> bool

Whether an h11.Request is a WebSocket handshake (Upgrade: websocket).

websocket_scope_from_request

websocket_scope_from_request(
    request: Request,
    *,
    scheme: str,
    server: tuple[str, int | None] | None,
    client: tuple[str, int] | None,
) -> WebsocketScope

Build the typed WebsocketScope an ASGI app expects from the handshake h11.Request.

ws_events_from_outbound

ws_events_from_outbound(
    outbound: WebsocketOutbound, *, accepted: bool
) -> list[Event]

Render one typed WebsocketOutbound as the wsproto events that put it on the wire.

accepted distinguishes the two meanings of a WebsocketClose: before the handshake is accepted it is a rejection (an HTTP response, here a 403); after, it is a normal close frame. This mirrors the ASGI interface that a close sent before websocket.accept becomes an HTTP denial.