without_http¶
A sans-IO-backed ASGI server and HTTP client for without: h11/h2/wsproto over asyncio sockets.
without_http
¶
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.
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).
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.
ssl_context_factory
class-attribute
instance-attribute
¶
ssl_context_factory: Callable[[], SSLContext] = (
ssl.create_default_context
)
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.
CookieJar
dataclass
¶
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.
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()yieldbytes, 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 asResponseTrailersafter the byte chunks. Reach for these only when you know (out of band) the endpoint uses trailers;read_with_trailersreturns 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.
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).
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.
Server
dataclass
¶
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.
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
¶
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
¶
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
¶
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
¶
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
¶
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.