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]

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,
    _h2: dict[Origin, _Http2Connection] = dict(),
    _idle_h11: dict[
        Origin, list[_Http11Connection]
    ] = dict(),
    _h11_only: set[Origin] = set(),
    _contexts: dict[tuple[str, ...], SSLContext] = dict(),
    _origin_locks: dict[Origin, Lock] = dict(),
    _closed: bool = False,
)

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.

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

request async

request(
    method: str,
    url: str,
    *,
    headers: RawHeaders = (),
    body: bytes | Stream[bytes] = b"",
    middleware: ClientMiddleware = _PASSTHROUGH,
) -> 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.

aclose async

aclose() -> None

CookieJar dataclass

CookieJar(
    _cookies: dict[tuple[str, str, str], _Cookie] = dict(),
)

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 deletion via Max-Age<=0. Not yet: Expires date-based expiry.

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

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.

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,
    ssl_context: SSLContext | None = None,
    ssl_handshake_timeout: float | None = None,
    ssl_shutdown_timeout: float | None = None,
) -> 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.

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.

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 contract that a close sent before websocket.accept becomes an HTTP denial.