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,
max_connections_per_host: int | None = None,
max_keepalive_per_host: int | None = None,
timeout: Timeout = _NO_TIMEOUT,
socket_options: SocketOptions = _DEFAULT_SOCKET_OPTIONS,
_h2: dict[Origin, _Http2Connection] = dict(),
_h11: dict[Origin, _HostPool] = dict(),
_h11_only: set[Origin] = set(),
_contexts: dict[tuple[str, ...], SSLContext] = dict(),
_origin_locks: dict[Origin, Lock] = dict(),
)
Connections keyed by origin, and the entrypoint for making requests.
Open it as an async context manager (async with ConnectionPool(...) as pool) so
its connections are closed on exit; a directly-constructed pool works for
short-lived use but does not manage the long-lived connections keep-alive retains.
Make requests through async with pool.request(...) as response.
HTTP/2 connections are kept and reused: many concurrent requests to one origin
multiplex over a single connection, which is the point of h2. HTTP/1.1
connections are kept too but used serially: an idle one is checked out for a
request and returned once its response body is read (keep-alive), so a fresh one
is opened only when none is idle. An h2 connection is negotiated over TLS by ALPN
when allow_http2 is set (the default; it is allowed, falling back to HTTP/1.1 if
the server does not offer it), or over cleartext by prior knowledge when
force_http2_cleartext is set (the caller asserting the server speaks h2c, since
cleartext cannot negotiate); otherwise the origin speaks HTTP/1.1.
ssl_context_factory produces the TLS client context (default
ssl.create_default_context). The pool calls it to build the contexts it opens
with and sets ALPN on them itself, holding one per distinct offer, so it never
mutates (nor shares the ALPN of) a context the caller holds. Pass a factory, not a
live context, precisely because ALPN can only be set context-wide: a shared context
would be mutated out from under other pools or libraries using it.
middleware decorates every request through the pool; per-request middleware on
request composes inside it. Keep pool-level middleware to pure decoration
(default headers, redirect following, retry): things that are values, not state.
Anything carrying mutable, request-spanning identity (a CookieJar, an auth
session) belongs in a value you own and pass per request, so that connection reuse
(a transport concern) and application identity stay independent rather than both
hiding in the pool.
max_connections_per_host bounds the number of concurrent HTTP/1.1 connections to
one origin: at the bound, a checkout waits for one to be returned (the wait a
pool timeout guards). It is unbounded by default, mirroring the server's choice
to let OS backpressure cap connections rather than an in-process limit; opt into a
bound when a caller wants explicit per-host backpressure.
max_keepalive_per_host bounds a different axis: how many idle HTTP/1.1 connections
are retained per origin once a burst subsides. At the cap, a returned connection is
closed instead of pooled, so the pool ramps up to max_connections_per_host under
concurrent load but settles back down to max_keepalive_per_host when quiet rather
than holding every socket open. It is unbounded by default (every reusable connection
is kept); a value above max_connections_per_host is never reached, since idle
connections cannot outnumber concurrent checkouts. Both knobs, when set, MUST be >=
1.
timeout bounds each phase of a request (see Timeout); it defaults to no timeouts,
since a deadline is the caller's policy, not the transport's. A per-request timeout
on request replaces it wholesale for that call.
socket_options is applied to every socket the pool opens, as (level, option, value)
triples. Build it by concatenating the pure producers in without_http.socket_options
(tcp_keepalive, send_buffer_size, ...), the way headers concatenate: each describes
one concern, and the pool hands the combined set to setsockopt in order. It defaults
to tcp_keepalive(), so a pooled connection is probed for a peer that vanished
silently; pass () for the kernel's defaults, or include tcp_keepalive(...) in a
longer set to keep probing while setting more.
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
request
async
¶
request(
method: str,
url: str,
*,
headers: RawHeaders = (),
body: bytes | Stream[bytes] = b"",
middleware: ClientMiddleware = _PASSTHROUGH,
timeout: Timeout | None = None,
) -> AsyncIterator[ClientResponse]
Send a request and yield its ClientResponse for the block, then release the connection.
body is the request body: bytes to buffer it, or a Stream[bytes] to stream
it. The yielded ClientResponse can be taken whole (response.head,
response.body) or unpacked (head, body = ...); read the response body with
async for chunk in body / await body.read(), or body.read_with_trailers()
when the endpoint carries trailers. On exit any unread body is drained or aborted
so the connection is never stranded.
middleware is composed inside the pool's own middleware, so a single request
can add decoration (a CookieJar via cookies, an extra header) on top of the
pool-wide stack for this call alone.
timeout replaces the pool's own timeout for this call (None inherits it).
It is captured as a value in the transport exchange, so it does not compose through
middleware the way decoration does; a deadline overrides, it does not layer.
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.
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.
ConnectTimeout
¶
Bases: HTTPTimeout
Establishing the connection to the origin took too long.
The request never reached the server, so it is always safe to retry, even a non-idempotent one, or to fail over to another origin.
HTTPTimeout
¶
Bases: TimeoutError
Base for a phase-specific client timeout, subclassing TimeoutError.
A coarse except TimeoutError catches any of them; the specific type tells the caller
how far the request got, which is what determines the safe recovery (the reason a
typed per-phase error beats a bare TimeoutError). See each subclass.
PoolTimeout
¶
Bases: HTTPTimeout
Acquiring a connection slot took too long (the per-host bound or h2 stream limit).
The request never left the process, so it is always safe to retry, though the real fix is usually local backpressure rather than retrying the peer.
ReadTimeout
¶
Bases: HTTPTimeout
Waiting for the next chunk of the response took too long.
The request was fully sent, so the server may already have processed it: retry only if the request is idempotent or carries an idempotency key, otherwise surface it. If it fired mid-body, the partial response already read is yours to keep or discard.
Timeout
dataclass
¶
Timeout(
connect: timedelta | None = None,
read: timedelta | None = None,
write: timedelta | None = None,
pool: timedelta | None = None,
)
Per-phase inactivity bounds for a client request, each disabled (None) by default.
Four axes, following httpx, each bounding one phase of a request that fails for its
own reason (see the without-http guide's request-lifecycle table):
connect: establishing the TCP (and TLS) connection to the origin.read: waiting for the next chunk of the response (re-armed per chunk, so it bounds the gap between chunks, not the whole read).write: making progress sending the next chunk of the request body (re-armed per write, so a lazily-fed body that pauses between chunks does not trip it).pool: waiting to acquire a connection slot, which only bites once a per-host bound (or the h2 stream limit) is in force.
Each axis is a timedelta, so the unit is explicit at the call site rather than an
ambiguous bare number. Every field defaults to None (that axis disabled), so the
default Timeout() bounds nothing: a timeout is a policy keyed to the caller's time
budget ("fail rather than make slow progress so my upstream can react"), which the
transport cannot know, so a caller opts in per axis (Timeout(connect=timedelta(
seconds=10), read=timedelta(seconds=30))). There is deliberately no shared-default
scalar: one duration across four unrelated phases carries no meaning. For an overall
wall-clock cap, compose async with asyncio.timeout(t): pool.request(...).
Each axis is applied through its own bound: connecting(), reading(), writing(),
and pooling() each return a context manager that bounds the wrapped await(s) by that
axis and raises its typed error on expiry. The mapping from axis to typed error lives
here, once, rather than at every call site that arms a deadline.
connecting
¶
connecting() -> AbstractAsyncContextManager[None]
Bound establishing the connection, raising ConnectTimeout on expiry.
reading
¶
reading() -> AbstractAsyncContextManager[None]
Bound awaiting the next response chunk, raising ReadTimeout on expiry.
writing
¶
writing() -> AbstractAsyncContextManager[None]
Bound making progress on the request body, raising WriteTimeout on expiry.
pooling
¶
pooling() -> AbstractAsyncContextManager[None]
Bound acquiring a connection slot, raising PoolTimeout on expiry.
WriteTimeout
¶
Bases: HTTPTimeout
Making progress sending the request body took too long.
The server saw at most a partial request. Retrying is safe for an idempotent request and ambiguous otherwise; the connection is discarded, so a retry gets a fresh one.
add_headers
¶
add_headers(
*headers: tuple[bytes, bytes],
) -> ClientMiddleware
Client middleware that adds headers to every request.
The mirror of a server's request-decorating middleware: it sits in the same
stack and rewrites the request before the inner exchange runs. This is how a
pool sends default headers (auth tokens, a user agent) on every request, or a
single request adds its own.
cookies
¶
cookies(jar: CookieJar) -> ClientMiddleware
Client middleware that carries cookies through a CookieJar you own.
Reads Set-Cookie off each response into jar and writes the matching Cookie
header onto each outgoing request. This is the stateful counterpart to add_headers:
its mutable jar is passed in explicitly rather than hidden in the pool, so two
requests share cookies exactly when they share a jar.
Place it inside follow_redirects in a stack (stack(follow_redirects(),
cookies(jar))) so each redirect hop both sends the jar's cookies and collects any
the hop sets.
follow_redirects
¶
follow_redirects(max_hops: int = 5) -> ClientMiddleware
Client middleware that follows 3xx redirects, up to max_hops.
Each intermediate response is drained before the next hop, so its connection is released. The follow re-issues the same request body, so redirects with a one-shot streaming body are not replayable; in practice redirects follow bodyless requests.
Credentials are not replayed across an origin boundary: when a hop's target has a
different scheme, host, or port, Authorization, Cookie, and
Proxy-Authorization are dropped before the request is re-issued. A hop that
would downgrade https to http is refused outright (the 3xx is returned
unfollowed) so nothing is replayed over cleartext.
stack
¶
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,
max_concurrent_streams: int = 100,
max_stream_resets: int = 200,
idle_timeout: timedelta | None = None,
max_websocket_message_bytes: int | None = None,
ssl_context: SSLContext | None = None,
ssl_handshake_timeout: float | None = None,
ssl_shutdown_timeout: float | None = None,
socket_options: SocketOptions = (),
) -> AsyncIterator[Server]
Serve app over HTTP for the duration of the with block.
Drives the lifespan cycle, binds a socket (port=0 picks a free one) with
asyncio.start_server, and yields a Server (its bound host/port, plus live
metrics like in_flight). On exit it stops accepting, cancels any in-flight
connections, and runs lifespan shutdown. To run a server until cancelled, hold the
block open with without.sleep_forever() (or your own run loop, e.g. one that
handles signals).
max_pending_connections is the kernel's listen backlog: the depth of the
queue of connections that have completed the TCP handshake but have not yet
been accepted. It absorbs accept bursts; once a connection is accepted it no
longer counts against it. When the queue is full, the OS handles further
connection attempts: on Linux the new SYN is dropped, so the client's
connect() retransmits and either succeeds once room frees or eventually
times out (a client may also see "connection refused" on platforms that
reset instead). Nothing is queued in the server process.
To bound in-flight requests (the right limit once HTTP/2 multiplexes many
requests over one connection), wrap the app in limit_concurrent_requests, which
sheds with a 503 rather than capping connections. This server does not cap raw
connections: the kernel listen backlog above and OS resource limits provide that
backpressure, and Server.in_flight reports the live connection count for metrics.
asyncio.start_server owns the accept loop, so it survives transient accept
failures (pausing for ACCEPT_RETRY_DELAY on resource exhaustion) and binds every
address host resolves to.
Four per-connection resource bounds harden a public deployment. idle_timeout
(a timedelta, off by default) closes a connection whose peer stops sending data
mid-exchange, defeating a slowloris; it also bounds an idle WebSocket. Over HTTP/2,
max_concurrent_streams is advertised as MAX_CONCURRENT_STREAMS and
max_stream_resets caps how many stream resets one connection may issue before it
is dropped, together defeating the Rapid Reset flood (CVE-2023-44487).
max_websocket_message_bytes (off by default) caps a reassembled WebSocket message.
Tune them at your composition root, e.g. from a settings value parsed by
without_env.EnvContext, rather than reaching for the environment here.
Pass ssl_context to serve https/wss directly; server_ssl_context builds
one for the common case. ssl_handshake_timeout bounds a single TLS handshake
(asyncio's default is 60s) and ssl_shutdown_timeout the closing close_notify
exchange (default 30s); both are meaningful only alongside ssl_context.
socket_options is applied to the listening socket, as (level, option, value)
triples built by concatenating the pure producers in without_http.socket_options
(receive_buffer_size, ...), the same way the client pool takes them. The kernel
hands a listening socket's buffer sizes down to every connection accepted on it, so
receive_buffer_size here bounds what the server will buffer from a peer whose body
it has not read yet. Options that only make sense per-connection have nothing to act
on at bind time; the default (()) leaves the kernel's own choices alone.
receive_buffer_size
¶
receive_buffer_size(size: int) -> SocketOptions
Pin the socket's receive buffer to size bytes (SO_RCVBUF).
The receive-side counterpart of send_buffer_size, with the same caveats and the same
guarantee: autotuning is bounded by
net.ipv4.tcp_rmem, whose docs say
"Calling setsockopt() with SO_RCVBUF disables automatic tuning of that socket's
receive buffer size, in which case this value is ignored". The cap is
net.core.rmem_max and the stored value is likewise doubled.
Set on a listening socket, it is inherited by every accepted connection, which is how a server bounds what it will buffer from a peer whose body it has not read yet.
send_buffer_size
¶
send_buffer_size(size: int) -> SocketOptions
Pin the socket's send buffer to size bytes (SO_SNDBUF).
Pinning it is what makes the buffer a known size. Left alone, Linux autotunes the
send buffer up to the max of the
net.ipv4.tcp_wmem sysctl, and
that sysctl's own documentation is the guarantee relied on here: "Calling
setsockopt() with SO_SNDBUF disables automatic tuning of that socket's send buffer
size, in which case this value is ignored."
Two caveats from socket(7),
which make this a bound rather than an exact reservation: the value is capped at
net.core.wmem_max, and "the kernel doubles this value (to allow space for bookkeeping
overhead) when it is set using setsockopt(2), and this doubled value is returned by
getsockopt(2)", so reading it back does not return what was set.
The sysctl names above are Linux's. SO_SNDBUF itself is POSIX and portable; what
varies elsewhere is only which knob bounds it.
tcp_keepalive
¶
tcp_keepalive(
*,
idle: timedelta = timedelta(seconds=60),
interval: timedelta = timedelta(seconds=10),
count: int = 6,
) -> SocketOptions
Enable TCP keepalive and tune its probe timing.
Once enabled, the OS probes an otherwise-idle connection and tears it down if the peer
has gone away without a TCP FIN: a crashed server, a network partition, a NAT or
firewall silently dropping the flow. A clean server-side keep-alive close sends a FIN,
which the pool already notices before reuse (_Http11Connection.usable); keepalive
covers the silent case, which matters most when request timeouts are disabled (the
default), since nothing else would notice a dead idle socket until a request hung on it.
idle: how long a connection sits idle before the first probe.interval: the gap between probes once they start.count: unanswered probes before the connection is declared dead.
So a broken idle connection is dropped roughly idle + interval * count after it goes
quiet. idle/interval MUST be a whole number of seconds, since the OS options carry
only integer seconds (a sub-second component is rejected here rather than silently
truncated); count is a plain probe count, not a duration.
Enabling keepalive (SO_KEEPALIVE) is portable, but the per-probe tuning is not
uniformly spelled or present, so the result includes each knob only where the running
platform exposes it and omits the rest (leaving that axis at the OS default):
- Linux:
idle/interval/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.
server_ssl_context
¶
server_ssl_context(
certfile: Path, keyfile: Path | None = None
) -> SSLContext
Build a server-side TLS context that serves the protocols without-http speaks.
Loads the certificate chain (a combined cert+key PEM if keyfile is omitted)
and advertises ALPN_PROTOCOLS, so a client negotiates the wire protocol
during the handshake. Pass the result to serving/serve as ssl_context to
serve https (and wss) directly.
This is a convenience for the common case. A caller needing more control (an
encrypted key, client-certificate verification, a custom cipher suite) builds
its own ssl.SSLContext and passes that instead.
is_websocket_upgrade
¶
is_websocket_upgrade(request: Request) -> bool
Whether an h11.Request is a WebSocket handshake (Upgrade: websocket).
websocket_scope_from_request
¶
websocket_scope_from_request(
request: Request,
*,
scheme: str,
server: tuple[str, int | None] | None,
client: tuple[str, int] | None,
) -> WebsocketScope
Build the typed WebsocketScope an ASGI app expects from the handshake h11.Request.
ws_events_from_outbound
¶
ws_events_from_outbound(
outbound: WebsocketOutbound, *, accepted: bool
) -> list[Event]
Render one typed WebsocketOutbound as the wsproto events that put it on the wire.
accepted distinguishes the two meanings of a WebsocketClose: before the
handshake is accepted it is a rejection (an HTTP response, here a 403);
after, it is a normal close frame. This mirrors the ASGI interface that a close
sent before websocket.accept becomes an HTTP denial.