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,
}
)
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.
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).
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.
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.
ssl_context_factory
class-attribute
instance-attribute
¶
ssl_context_factory: Callable[[], SSLContext] = (
ssl.create_default_context
)
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
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.
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).
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()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.
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.
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.
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.
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:
resolveturns the name into candidate addresses, defaulting to the OS resolver; seeResolvefor what plugs in here.aiohappyeyeballsconnects, racing address families per RFC 8305 (Happy Eyeballs) withhappy_eyeballs_delaybetween 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;Nonetries the addresses strictly in turn. The race drives plainloop.sock_connect, so it behaves the same on any event loop, and it takes already-resolved addresses, which is what letsresolvebe 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
¶
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,
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
¶
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/countareTCP_KEEPIDLE/TCP_KEEPINTVL/TCP_KEEPCNT. - macOS: there is no
TCP_KEEPIDLE; the idle knob isTCP_KEEPALIVE(used the same way), withTCP_KEEPINTVL/TCP_KEEPCNTfor 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_KEEPALIVEis 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:
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-
200status or a content type other thantext/event-streamraisesNotAnEventStreamand 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
¶
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
¶
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.