Skip to content

without_http

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

without_http

DEFAULT_DECOMPRESSORS module-attribute

DEFAULT_DECOMPRESSORS: MappingProxyType[
    bytes, Callable[[], Decompressor]
] = MappingProxyType(
    {
        b"br": _brotli_decompressor,
        b"gzip": _gzip_decompressor,
        b"zstd": zstd.ZstdDecompressor,
    }
)

GZIP_CONTAINER module-attribute

GZIP_CONTAINER = zlib.MAX_WBITS | 16

USER_AGENT module-attribute

USER_AGENT = (
    f"without-http/{metadata.version('without-http')}"
)

DEFAULT_RECONNECT module-attribute

DEFAULT_RECONNECT = timedelta(seconds=3)

MAXIMUM_RECONNECT module-attribute

MAXIMUM_RECONNECT = timedelta(minutes=5)

MINIMUM_RECONNECT module-attribute

MINIMUM_RECONNECT = timedelta(milliseconds=100)

ALPN_PROTOCOLS module-attribute

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

ClientMiddleware

ClientMiddleware = Endo[Client]

SocketOptions

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

ClientRequest dataclass

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

A client request as a value: the head, a streaming body, and its deadline.

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 Client transforms, middleware can rewrite it: add headers, change the URL, wrap the body, extend the deadline.

timeout bounds each phase of this request (see Timeout), and defaults to no bounds at all. It rides on the request rather than on the transport because a deadline is the caller's policy, not the connection's: it is the caller's time budget that decides when slow progress is worse than failure. Carrying it here is what lets a Client be a plain one-argument function, and what lets middleware (deadline, or a retry that shortens each attempt) set it like any other field.

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)

timeout class-attribute instance-attribute

timeout: Timeout = _NO_TIMEOUT

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 client(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. request yields this value and closes body on exit; it is also what a ClientMiddleware rewrites (by constructing a new one, since a NamedTuple has no dataclasses.replace).

head instance-attribute

body instance-attribute

Compressor

Bases: Protocol

The incremental compressor shape compress drives: feed chunks through compress, then flush ends the stream. zlib.compressobj and zstd.ZstdCompressor satisfy it as-is, and a third-party codec plugs in with whatever thin adapter its own surface needs.

This is the lesser of two rungs, and which one a table entry lands on decides what happens to a streaming body: a codec that satisfies only this protocol can be emptied only by ending the stream, which is enough for a response that arrives whole and not for one still being produced. Prefer the StreamingCompressor that gzip_compressor, zstd_compressor, and brotli_compressor produce over a raw codec, which reaches only this rung.

The same two shapes drive without-http's request-side compressing, which re-exports both, so one adapter serves both directions.

compress

compress(data: bytes) -> bytes

flush

flush() -> bytes

Connect

Bases: Protocol

How a pool reaches an origin: the one step that touches the network.

Injected into ConnectionPool so the rest of it (reuse, bounds, protocol selection) stays independent of how a connection is made. tcp_connect() builds the default; a test dials an in-memory pipe, and a unix-socket or proxy connector would slot in the same way. The negotiated wire protocol comes back alongside the streams because only the connector can know it: ALPN is read off the finished handshake.

ConnectionPool dataclass

ConnectionPool(
    allow_http2: bool = True,
    force_http2_cleartext: bool = False,
    ssl_context_factory: Callable[
        [], SSLContext
    ] = create_default_context,
    connect: Connect = _open,
    max_connections_per_host: int | None = None,
    max_keepalive_per_host: int | None = None,
    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: the Client that answers a request over the network.

Calling it is the request (await pool(request)), so a pool is interchangeable with any other Client and composes with ClientMiddleware through stack. Most callers go through the request context manager rather than calling it directly, since that builds the ClientRequest and closes the response body for them.

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.

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.

connect is how the pool reaches an origin, defaulting to a TCP connect that races dual-stack addresses per RFC 8305 (see Connect and tcp_connect, whose knobs also inject the resolver). It is the only part of the pool that touches the network, so replacing it points the same pooling, protocol selection, and keep-alive at somewhere else.

Decoration (default headers, redirect following, cookies, a deadline) is not a pool concern: compose it around the pool with stack, which yields another Client. That keeps connection reuse (a transport concern) and application identity 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.

Deadlines ride on the request (ClientRequest.timeout), not on the pool: the same pool serves callers with different time budgets, and a bound stored here would be a property of the connection rather than of the caller that wanted it.

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
)

connect class-attribute instance-attribute

connect: Connect = _open

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

socket_options class-attribute instance-attribute

socket_options: SocketOptions = _DEFAULT_SOCKET_OPTIONS

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.

Decompressor

Bases: Protocol

The incremental decoder shape decompress drives: feed encoded chunks through decompress, with eof reporting whether a complete compressed stream has been seen (the truncation check relies on it) and unused_data holding the bytes that followed it (the next stream, for a coding that concatenates). zlib.decompressobj and zstd.ZstdDecompressor satisfy it as-is; a third-party codec plugs in with a thin adapter (e.g. mapping brotli's is_finished() to eof).

eof property

eof: bool

unused_data property

unused_data: bytes

decompress

decompress(data: bytes) -> bytes

Resolve

Bases: Protocol

How a connector turns a name into candidate addresses: the resolution step alone.

Injected into tcp_connect, defaulting to the operating system's resolver (loop.getaddrinfo), so resolution policy swaps without touching how the winning address is connected: a cache keyed however the caller likes, DNS over HTTPS, or a test's canned addresses. It returns getaddrinfo-shaped tuples (aiohappyeyeballs' AddrInfoType), which the connect race consumes directly. A cache lives in a wrapper here rather than in the pool, and its staleness bound is the caller's policy to choose, since getaddrinfo does not surface record TTLs.

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

StreamingCompressor

Bases: Compressor, Protocol

A Compressor that can also make what it has swallowed deliverable without ending the stream.

Every codec buffers, and what any one compress call returns is the codec's choice rather than the caller's: fed the small pieces a streaming body arrives in, zlib emits its header and then nothing until flush, and zstd emits nothing at all. That is right for a body held whole, where the buffering is what buys the ratio, and wrong for one being streamed, where it converts incremental delivery into a single burst at the end and grows the held bytes without bound. flush_block ends the current block so everything fed so far decodes now, and leaves the stream open for what follows.

A separate protocol rather than a third method on Compressor, because a codec without one is still perfectly good at the buffered case and there is no reason to shut it out of the table. compress reads the difference at the point it matters: a coding whose factory produces a plain Compressor encodes responses that arrive whole and leaves streaming ones unencoded, which costs bytes rather than latency.

flush_block

flush_block() -> bytes

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

NotAnEventStream

Bases: Exception

The endpoint did not answer with a 200 text/event-stream response.

Terminal, never retried: the spec fails the connection on exactly these two conditions and does not reconnect afterwards, because an endpoint answering 404 or text/html is not a stream that dropped, it is one that was never there.

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 one 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. It rides on the ClientRequest it bounds (deadline fills it in for a whole client), so it is the caller's value rather than the connection's. For an overall wall-clock cap, compose async with asyncio.timeout(t): 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 client runs. This is how a caller sends the same headers on every request, or a single request adds its own.

Every request gets a copy, whatever it already carries, which is what a field that may appear more than once (accept, via, a custom trace header) wants. default_headers is the counterpart for a field that may not.

basic_auth

basic_auth(
    username: str, password: str
) -> ClientMiddleware

Client middleware that sends Basic authorization (RFC 7617) on every request.

The credentials are fixed at composition, base64 of username:password encoded as UTF-8 (the charset RFC 7617 names and servers expect today). A colon in username is refused with ValueError rather than encoded: the receiver splits the decoded pair at the first colon, so the colon would silently move characters from the username into the password.

This is a default rather than a policy: a request carrying its own authorization keeps it, so one call can authenticate as someone else without composing a second client.

bearer_auth

bearer_auth(
    token: str, *, scheme: str = "Bearer"
) -> ClientMiddleware

Client middleware that sends bearer-style authorization on every request.

Sends authorization: Bearer <token> (RFC 6750) by default. The scheme prefix is the part real APIs disagree on, so it is injectable: pass the spelling the peer demands (scheme="Token", scheme="token"), or scheme="" to send the bare token with no prefix at all. The header encodes as ASCII, which is the charset both the scheme and a bearer token are allowed, so a stray non-ASCII byte fails loudly here rather than on the wire.

Like basic_auth, this is a default: a request carrying its own authorization keeps it.

brotli_compress

brotli_compress(quality: int = 11) -> ClientMiddleware

Client middleware that brotli-compresses every request body sent through it.

gzip_compress's sibling over brotli (Google's own bindings, a bundled dependency since the stdlib has no brotli): everything there holds here, and only the coding differs. quality is brotli's compression quality (0-11), defaulting to the bindings' own default of 11, the maximum, because a client compressing an upload it holds whole is the case that ratio is worth paying for. The server-side compress table defaults lower, since it encodes per response; see without_asgi.compression.brotli_compressor, the shared codec behind both.

brotli_compressor

brotli_compressor(
    quality: int = DYNAMIC_BROTLI_QUALITY,
) -> StreamingCompressor

A fresh brotli Compressor, the codec behind DEFAULT_COMPRESSORS' br entry.

quality is brotli's compression quality (0-11), defaulting to DYNAMIC_BROTLI_QUALITY rather than the bindings' own 11: a table entry encodes a response per request, where 11 costs much more CPU without a ratio to show for it at response sizes. Raise it for a table serving bodies large enough for the wider window to pay, or content compressed once (compress(DEFAULT_COMPRESSORS | {b"br": lambda: brotli_compressor(11)})).

Called with no argument it is already the zero-argument factory a table wants. without-http's request-side brotli_compress drives the same adapter, keeping its own default at 11, since a client compressing one upload is the static case again.

compressing

compressing(
    coding: bytes, make_compressor: Callable[[], Compressor]
) -> ClientMiddleware

A ClientMiddleware that encodes every request body with coding.

The mechanism behind gzip_compress and zstd_compress, public so any coding those two do not ship arrives the same way: name the content-encoding token and supply a factory for a fresh Compressor per request (compressing(b"br", make_brotli)), and the rest is inherited rather than reimplemented.

That rest: the lazy body Stream[bytes] is wrapped in the incremental compressor, so a streamed upload compresses chunk by chunk and holds no more than one of them; and the framing follows the rewrite, content-length no longer describes the body, so it is dropped and the compressed stream goes out transfer-encoding: chunked (over HTTP/2 the framing header is dropped with the other hop-by-hop headers and the body rides DATA frames as usual).

Holding no more than a chunk is a demand on the codec, not just on the loop: what compress returns is the codec's choice, and fed a chunk at a time zlib and zstd return almost nothing until the stream ends, which would buffer the whole upload inside the codec while looking like it streamed. make_compressor should therefore produce a StreamingCompressor, as gzip_compressor, zstd_compressor, and brotli_compressor all do; a plain Compressor still encodes correctly and still buffers, because a coding named by the caller has no unencoded answer to fall back on the way a negotiated response does. A body that arrives whole is one chunk either way, and encodes to the same bytes under both.

Two kinds of request pass through untouched: one already carrying a content-encoding (the body is already encoded; re-compressing would corrupt it), and one with no body at all (neither content-length nor transfer-encoding, which is how request frames bodyless requests), so a plain GET does not grow an empty-payload compressed shell.

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.

deadline

deadline(timeout: Timeout) -> ClientMiddleware

Client middleware that applies timeout to every request that bounds nothing itself.

A default time budget for everything sent through the composed client, in the same stack as any other decoration. A request that bounds any phase of its own keeps its own timeout whole, so a caller with a tighter budget for one call is not overridden by the default; that is the difference between a default and a policy imposed from above. A request bounding nothing (the default Timeout()) reads as "no budget stated" rather than "no budget wanted", so it takes the default: a caller who wants one request exempt composes it against a client without this middleware.

decompress

decompress(
    decompressors: Mapping[
        bytes, Callable[[], Decompressor]
    ] = DEFAULT_DECOMPRESSORS,
) -> ClientMiddleware

Client middleware that negotiates and decodes compressed response bodies.

Outbound it offers accept-encoding (a request already carrying its own keeps it); inbound it wraps the response body stream in an incremental decoder, so a streamed body decodes chunk by chunk and trailers pass through untouched. Middleware rather than pool behavior, so the transport never silently rewrites bytes: a caller that wants the wire encoding reads the undecorated client.

decompressors maps each coding to a factory for a fresh Decompressor, defaulting to DEFAULT_DECOMPRESSORS (gzip and zstd via the stdlib, brotli via the bundled bindings). The offer is derived from its keys, so what is advertised and what is decoded cannot disagree; register a coding this package does not ship by extending the table (decompress(DEFAULT_DECOMPRESSORS | {b"lzma": make_lzma})) with any factory whose product satisfies Decompressor. The mapping is snapshotted, keys lowercased, when the middleware is built.

The decoded response is self-consistent: content-encoding and content-length described the encoded body, so both are dropped from the head rather than left to contradict the bytes the stream now yields. A response whose content-encoding is not in the table (an unknown coding, or a stack of them) passes through whole, head and body untouched.

A truncated compressed stream raises ConnectionError rather than passing off a prefix as the whole body, and corrupt bytes raise the codec's own error. A body that concatenates streams (multi-member gzip, back-to-back zstd frames, both of which the formats define and origins do serve) decodes whole: each decoder that ends hands its leftover bytes to a fresh one.

default_headers

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

Client middleware that adds each of headers to a request not already carrying it.

The shape a default takes for a field RFC 9110 allows only once (authorization, user-agent, an API key): add_headers would prepend a second copy, leaving the peer to resolve a duplicate the spec says cannot happen, so the per-request value the caller wrote to override the default is the one that silently loses. Each header is decided on its own, so a request stating one default and not another gets exactly the one it omitted.

This is a default, not a policy: the request's own value wins. A caller that must not be overridden composes its own client rather than handing out one that can be, the same position deadline takes on a time budget.

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.

gzip_compress

gzip_compress(
    level: int = Z_DEFAULT_COMPRESSION,
) -> ClientMiddleware

Client middleware that gzips every request body sent through it.

Requests have no accept-encoding negotiation, so this stays opt-in: compose it onto the clients whose upstreams are known to accept gzip requests, while the same pool backs uncompressed clients beside them. The scope is wherever the composition happens: decorate once at assembly for a whole client, or inline at one call site (request(gzip_compress()(client), ...)) for a single request, since decorating a client is a stateless function wrap.

The body streams through an incremental compressor and the framing follows the rewrite (see compressing for both, and for which requests pass through untouched). level is zlib's compression level, defaulting to zlib's own default. zstd_compress and brotli_compress are the same middleware over their codings, and compressing is the shared mechanism for any coding beyond those; the response-side counterpart to all of them is decompress.

gzip_compressor

gzip_compressor(
    level: int = Z_DEFAULT_COMPRESSION,
) -> StreamingCompressor

A fresh gzip StreamingCompressor, the codec behind DEFAULT_COMPRESSORS' gzip entry.

level is zlib's compression level, defaulting to zlib's own default. Called with no argument it is already the zero-argument factory a table wants.

Public because zlib.compressobj is not a substitute for it: the raw object is a Compressor and not a StreamingCompressor, since ending a block is a mode argument to its flush rather than a method of its own, so a table entry built from it directly would silently take the buffered path for every streaming response. without-http's request-side gzip_compress drives this same factory.

request async

request(
    client: Client,
    method: str,
    url: str,
    *,
    headers: RawHeaders = (),
    body: bytes
    | Stream[bytes]
    | Content
    | StreamingContent = b"",
    timeout: Timeout = _NO_TIMEOUT,
) -> AsyncIterator[ClientResponse]

Send a request through client and yield its ClientResponse for the block.

The one request surface, over any Client: a ConnectionPool, a pool wrapped in middleware (stack(add_headers(...), cookies(jar))(pool)), or an in-memory one from without_http.testing. It owns the two things a caller would otherwise repeat: the body framing (bytes gets a content-length, a Stream[bytes] gets transfer-encoding: chunked) and closing the response body on the way out, so a connection is never stranded by a body nobody read.

body takes bytes, a Stream[bytes] to stream them, or a Content / StreamingContent when the caller holds an encoding rather than bare bytes: body=json_content(order) or body=multipart_content(...) sends the body and the content-type describing it together, since neither is any use without the other.

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.

timeout bounds this request's phases (see Timeout), defaulting to no bounds. It lands on the ClientRequest, so middleware sees and can rewrite it like any other field; deadline sets the same field for every request through a client.

tcp_connect

tcp_connect(
    *,
    resolve: Resolve = _getaddrinfo,
    happy_eyeballs_delay: timedelta
    | None = _HAPPY_EYEBALLS_DELAY,
) -> Connect

A Connect over TCP: resolve the name, race the addresses, negotiate ALPN.

This builds the default connect (ConnectionPool() behaves as ConnectionPool(connect=tcp_connect())), with its two steps injectable:

  • resolve turns the name into candidate addresses, defaulting to the OS resolver; see Resolve for what plugs in here.
  • aiohappyeyeballs connects, racing address families per RFC 8305 (Happy Eyeballs) with happy_eyeballs_delay between attempts (default 250 ms, the RFC's recommendation), so a dual-stack host with one black-holed family costs one delay rather than a full connect timeout; None tries the addresses strictly in turn. The race drives plain loop.sock_connect, so it behaves the same on any event loop, and it takes already-resolved addresses, which is what lets resolve be a separate step at all (asyncio's own racing is fused to its own resolution).

The returned connector reports the negotiated wire protocol alongside the streams. ssl_context is None for cleartext, which has no negotiation and is always http/1.1 (prior-knowledge h2c is opened directly by the pool instead), or a ready context whose ALPN offer the pool has already settled; over TLS the protocol is whatever ALPN selected. The connect timeout bound covers resolution, the connect race, and, over TLS, the handshake. socket_options is applied to the winning socket exactly as given.

user_agent

user_agent(*segments: str) -> ClientMiddleware

Client middleware that sends a user-agent header on every request.

No user-agent is sent unless a caller composes this middleware (or writes the header itself): requests say exactly what the caller said. Some peers refuse an absent user-agent outright (the GitHub API 403s such requests), so this is the first header most callers reach for.

segments are joined with single spaces, the separator RFC 9110 defines between product tokens, so user_agent("myapp/1.0", USER_AGENT) sends myapp/1.0 without-http/<version>. With no segments, the header is USER_AGENT alone: the same library-identity default httpx, requests, aiohttp, and niquests send, opted into rather than unbidden. The value encodes as ASCII, failing loudly here rather than on the wire. A request carrying its own user-agent keeps it: user-agent is a singleton field, so this is a default, not a policy.

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 Client wrapper.

zstd_compress

zstd_compress(level: int | None = None) -> ClientMiddleware

Client middleware that zstd-compresses every request body sent through it.

gzip_compress's sibling: everything there (why it is opt-in, how the body streams, which requests pass through untouched) holds here, and only the coding differs. gzip is the coding everything decodes; reach for zstd where the upstream is known to decode it.

level is zstd's compression level, defaulting to the library's own default.

zstd_compressor

zstd_compressor(
    level: int | None = None,
) -> StreamingCompressor

A fresh zstd StreamingCompressor, the codec behind DEFAULT_COMPRESSORS' zstd entry.

level is zstd's compression level, defaulting to the library's own. Everything in gzip_compressor about why the raw zstd.ZstdCompressor is not a substitute applies here: it spells a block flush as a mode argument too.

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,
    extensions: Mapping[
        str, Mapping[str, object]
    ] = HTTP_EXTENSIONS,
) -> 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, and the extensions this connection offers, which is HTTP_EXTENSIONS plus tls when the connection is over TLS). 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,
    extensions: Mapping[
        str, Mapping[str, object]
    ] = HTTP_EXTENSIONS,
) -> 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, and the extensions this connection offers, which is HTTP_EXTENSIONS plus tls when the connection is over TLS). 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 = max_concurrent_streams,
    max_stream_resets: int = max_stream_resets,
    idle_timeout: timedelta | None = idle_timeout,
    max_websocket_message_bytes: int
    | None = max_websocket_message_bytes,
    max_incomplete_event_bytes: int = max_incomplete_event_bytes,
    max_header_list_bytes: int = max_header_list_bytes,
    close_timeout: timedelta = close_timeout,
    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.

Seven 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.

The last two bound the request head, one per protocol, because the two protocols measure it differently rather than as a matter of taste. max_incomplete_event_bytes is h11's max_incomplete_event_size: how many bytes of a not-yet-complete HTTP/1.1 event (a request line plus its headers, or a chunk header) may accumulate before the parse is abandoned, so a peer cannot dribble an endless header block. max_header_list_bytes is advertised over HTTP/2 as MAX_HEADER_LIST_SIZE, bounding the uncompressed size of a header list, which is what makes it a defense against an hpack bomb. Each defaults to its protocol library's own default (16 KiB and 64 KiB), so the numbers differ; raise them together if your peers send large headers.

close_timeout (5 seconds) bounds the other end of a connection's life: how long a closing connection waits for a response it has already queued to reach the peer. Asyncio hands the socket back only once that buffer drains, so a peer that stops reading would otherwise hold the file descriptor, and hold a shutdown, indefinitely; past the bound the connection is aborted and the peer loses whatever was still in flight. Raise it for large responses to slow clients, lower it for a tighter shutdown.

Tune all of these at your composition root, e.g. from a settings value parsed by without_env.EnvContext, rather than reaching for the environment here.

Over TLS, every scope carries the tls extension with the negotiated version and the client certificate chain, read once per connection off the finished handshake; see tls_extension for the fields CPython's ssl module cannot supply.

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: Seconds = _KEEPALIVE_IDLE,
    interval: Seconds = _KEEPALIVE_INTERVAL,
    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 are counts of Seconds because the OS options carry only integer seconds: a finer duration is not something either can be built from, so nothing is silently truncated on the way to the socket. 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.

subscribe

subscribe(
    attempt: Callable[
        [RawHeaders], Awaitable[ClientResponse]
    ],
    *,
    reconnect: timedelta = DEFAULT_RECONNECT,
    minimum_reconnect: timedelta = MINIMUM_RECONNECT,
    maximum_reconnect: timedelta = MAXIMUM_RECONNECT,
    max_event_size: int | None = None,
    sleep: Callable[[timedelta], Awaitable[None]] = _sleep,
) -> AsyncGenerator[ReceivedEvent]

Consume an event stream, reconnecting and resuming when it drops.

The composition the two halves of Server-Sent Events exist to be assembled into: it opens a connection, parses the response body with without_asgi.sse.parse_events_with_directives, and when the stream ends it waits and opens another one carrying Last-Event-ID, so the producer can resume where the consumer stopped. What a caller sees is one uninterrupted stream of events across however many connections it took.

async for event in subscribe(lambda headers: client(ClientRequest("GET", url, headers))):
    print(event.type, event.data)

attempt opens one connection: given the headers this loop wants on the request, it answers with the response. A function rather than a ClientRequest, because a request is not replayable. Its body is a Stream[bytes], which the interface allows to be iterated exactly once, and the bodies this package builds are one-shot async generators, so re-sending one request value would put a full body on the wire for the first attempt and an empty one on every attempt after it. Building the request inside attempt makes that unrepresentable rather than documented, and it is what lets an event stream ride a POST (the shape MCP's Streamable HTTP uses) instead of only the bodyless GET a reused request survives.

The headers handed to attempt are accept: text/event-stream and, once the stream has a resumption point, last-event-id. Pass them through as above, or merge them with your own to decide which side wins on a name you also set.

Descending a layer is the whole point of the split, and costs one line. A caller that wants exactly one connection, or its own reconnection policy, skips this and parses the body directly:

head, body = await client(request)
async for event in parse_events(body):
    ...
What it retries, and what it does not

This is the only retry loop without-http ships, and the register's position against a retry() middleware is why it can be: that position rejects policy the library would have to invent (how many attempts, which statuses, what backoff), and here there is none to invent. The backoff arrives on the wire as retry:, the resumption token arrives as id:, and the terminal condition is written into the protocol. What the settings below decide is how far to trust the peer that supplies them.

  • A non-200 status or a content type other than text/event-stream raises NotAnEventStream and never reconnects, per the spec's terminal failure.
  • The first connection's errors propagate. A caller that cannot reach the endpoint at all learns so immediately instead of watching a silent loop.
  • Once a stream has been established, a connection error or timeout, on the stream or on any later attempt, reconnects after the current wait. A stream that a proxy reaps every 60 seconds is the ordinary case, not the exceptional one, which is why the protocol has a resumption token at all.

reconnect is the wait until the producer names one with retry:, after which its value is used, clamped to between minimum_reconnect (100ms) and maximum_reconnect (five minutes). Both ends guard the same thing, a retry: that is hostile or merely wrong: at zero it would spin a consumer into a hot reconnect loop, and a few orders of magnitude too large it would park one on a subscription that goes silent forever with nothing raised to notice. Widen either end for a producer you trust to name its own backoff, or narrow them to hold a peer to a window you chose. max_event_size is passed through to the parser. sleep is the delay, injected so a test drives the loop without waiting (and so a caller can add jitter).

Reflecting a producer's value into a request header is safe here because the parser only ever hands on an id a header can carry unchanged: a carriage return or line feed is what ended the field, and an id: a field value could not spell, or would silently alter, is ignored rather than resumed from.

An AsyncGenerator rather than a bare AsyncIterator, because this holds a live connection and a caller that stops early should be able to say so: aclose() releases it there and then, rather than at whenever the collector gets to it.

distinguished_name

distinguished_name(
    subject: Iterable[Iterable[tuple[str, str]]],
) -> str

Render a certificate subject as its RFC 4514 string, the form the tls ASGI extension asks for.

Takes the shape ssl.SSLObject.getpeercert() reports a subject in: a sequence of relative distinguished names, each a sequence of attribute pairs. RFC 4514 orders the output most-specific first, which is the reverse of that sequence, joins multi-valued names with +, and escapes the characters that would otherwise separate one attribute from the next.

extensions_with_tls

extensions_with_tls(
    extensions: Mapping[str, Mapping[str, object]],
    tls: Mapping[str, object] | None,
) -> Mapping[str, Mapping[str, object]]

Add the tls extension to a scope's extensions, or return them unchanged.

None means the connection is not over TLS, which the extension's absence is how an application detects. Callers build this once per connection rather than per request, since a connection's TLS facts do not change under it.

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.

tls_extension

tls_extension(
    ssl_object: SSLObject,
) -> Mapping[str, object]

Read the tls ASGI extension info off a finished handshake, for a scope's extensions mapping.

Two of the extension's fields are None here because CPython's ssl module does not surface them, rather than because the connection lacks them: an ssl.SSLContext never exposes the certificate it was loaded with (server_cert), and SSLObject.cipher() reports the suite by name with no IANA identifier (cipher_suite). The spec permits None for both. client_cert_error is None because a client certificate that fails verification fails the handshake, so no scope is ever built for it.

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,
    extensions: Mapping[
        str, Mapping[str, object]
    ] = WEBSOCKET_EXTENSIONS,
) -> WebsocketScope

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

extensions is what this connection offers: WEBSOCKET_EXTENSIONS, plus tls when the handshake arrived over TLS.

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.