without-http¶
A sans-IO-backed ASGI server and HTTP client for without. Where
without-asgi is the app side of the ASGI boundary (it turns
a server's receive/send into typed streams), without-http is the server
side: it owns the socket and the HTTP wire protocol, and drives any ASGI app via
app(scope, receive, send). See the
without_http API reference for the full surface.
The wire-protocol state machines are themselves sans-IO libraries:
h11 for HTTP/1.1,
h2 for HTTP/2, and
wsproto for WebSockets.
without-http reads and writes socket bytes with asyncio, feeds them through
those state machines, and uses without-asgi's server-direction codecs to
translate between typed events and the ASGI dicts an app expects.
Server¶
from without import sleep_forever
from without_asgi import make_asgi_app
from without_http import serving
app = make_asgi_app(lifespan, http=router.dispatch, websocket=sockets.dispatch)
async with serving(app, host="127.0.0.1", port=8000):
await sleep_forever() # run until cancelled
Because without-http speaks plain ASGI to the app, any ASGI app runs over it,
interchangeably with uvicorn: a without-web router, a bare
without-asgi handler, or a third-party app (Starlette, FastAPI).
serving(app, ...) is the entrypoint: an async context manager that drives the
lifespan cycle, binds the socket (pass port=0 to let the OS pick), yields a
Server, and shuts down cleanly on exit. There is no separate run-until-cancelled
wrapper: hold the block open however you like, with sleep_forever() for the simple
case or your own loop (signal handling, several servers under asyncio.gather). The
yielded Server exposes the bound address and live metrics:
async with serving(app, port=0) as server:
... # hit http://{server.host}:{server.port}; server.in_flight is the live count
What the server handles:
- Lifespan. The app is run once with a
lifespanscope for the server's lifetime:startupon entry,shutdownon exit. An app that does not support lifespan signals so by raising before it acks startup; the server then serves without a lifespan cycle (the standard ASGI fallback). - TLS. Pass an
ssl.SSLContextasssl_contextto servehttps/wssdirectly (the scope'sschemebecomeshttps/wss).server_ssl_contextbuilds one for the common case, advertising the protocols the server speaks via ALPN.ssl_handshake_timeoutandssl_shutdown_timeoutbound the TLS handshake and close. - HTTP/2. Selected by ALPN (
h2) over TLS, or by prior knowledge over cleartext (the h2 connection preface is sniffed off the first bytes, sinceh11would mis-parsePRIas an HTTP/1 method). Each request stream drives its own ASGI app invocation, so many run concurrently over one connection; a single lock serializes the sharedh2.Connectionand the writer, and body sends respect per-streamWINDOW_UPDATEflow control. The samewithout-asgiserver-direction codecs carry over; only the wire mapping (h2_wire) is new. - Keep-alive. Sequential requests on one HTTP/1.1 connection reuse it
(
h11'sstart_next_cycle). Reuse turns on the request being fully received, not on the app having read it: an app that ignoresreceiveentirely (as FastAPI does on a body-lessGET) keeps its connection, because the events it left unread are consumed fromh11's buffer once it responds. A connection whose peer is still sending when the response goes out (an early response, so the body never fully arrived) is closed gracefully instead, with a bounded lingeringFINrather than a reset that could discard the response: see Security. - WebSockets over the HTTP/1.1
Upgrade: the handshake is handed towsproto, and the connection runs full-duplex (a reader pump feeds inbound frames to the app'sreceivewhilesendwrites outbound frames). Awebsocket.closesent beforewebsocket.acceptbecomes an HTTP403, per the ASGI interface. - Isolation. A crashing request handler is contained: it becomes a
500(when no response has started yet) without taking the connection or server down. - Connections. Served via
asyncio.start_server, which owns the accept loop (surviving transient accept errors with its built-in retry delay) and binds every addresshostresolves to.max_pending_connectionsis the kernel listen backlog (the queue of accepted-by-the-OS-but-not-yet-served connections; when it fills, the OS drops or refuses further connection attempts). The server does not cap raw connections: the backlog and OS resource limits provide that backpressure, andServer.in_flightreports the live connection count for metrics. To bound in-flight requests (the right limit once one HTTP/2 connection multiplexes many requests), wrap the app inlimit_concurrent_requests, which sheds with a503. - Resource bounds.
servingtakes per-connection bounds for a hostile network, off or generous by default and tuned at the composition root (e.g. from anEnvContextsettings value).idle_timeout(atimedelta) closes a connection whose peer stalls mid-exchange (slowloris) and bounds an idle WebSocket. Over HTTP/2,max_concurrent_streamsis advertised andmax_stream_resetscaps how many resets one connection may issue before it is dropped, together defeating the Rapid Reset flood (CVE-2023-44487); a client reset also cancels the stream's app task, and received body is acked only as the app consumes it, so the flow-control window bounds buffered body.max_websocket_message_bytescaps a reassembled WebSocket message. For a body-size cap that works under any transport, wrap the app inwithout-asgi'slimit_request_body, which answers413.
The pure wire cores (h11_wire, h2_wire, ws_wire) are sans-IO and unit-tested:
they map h11/h2/wsproto events to the typed without-asgi vocabulary and
back, with no sockets. The asyncio shell (server.py) is the only part that
touches I/O.
Client¶
The client is a ConnectionPool you open once and make requests through, not free
get/post functions:
from without_http import ConnectionPool
async with ConnectionPool() as pool:
async with pool.request("GET", "http://127.0.0.1:8000/items") as (head, body):
assert head.status == 200
data = await body.read()
The response: a (head, body) split¶
pool.request yields a ClientResponse, which is a NamedTuple, so take it whole or
unpack it as you like:
async with pool.request("GET", url) as response: # response.head, response.body
...
async with pool.request("GET", url) as (head, body): # unpacked, types preserved
...
head is a ResponseHead (status + headers), a value you branch on immediately;
body is a ResponseBody, a live stream you consume separately. This mirrors how the
server consumes a request (a scope value plus a body stream): the structured head
is pulled out as a value so you can decide what to do before touching the body.
head is without-http's own inbound type, deliberately not without-asgi's outbound
ResponseStart even though the fields match: a type the parser fills from the wire
has no defaults (so a missing field fails loudly), while an outbound type an app
builds carries them for ergonomics. Same split as without-asgi's RequestBody
(inbound) versus ResponseBody (outbound).
Buffered and streaming, both directions¶
Request and response bodies each cover the full buffered/streaming matrix, the
client mirror of without-web's server handlers. The request body is body=
on pool.request: pass bytes to buffer it, or a Stream[bytes] (any async
iterable of chunks) to stream it. The response body is a live stream: iterate it
chunk by chunk, or await body.read() to buffer the whole thing.
async def upload() -> AsyncIterator[bytes]:
for path in paths:
yield path.read_bytes()
async with pool.request("POST", url, body=upload()) as (head, body):
async for chunk in body: # stream the response as it arrives
sink.write(chunk)
The connection is released when the body is finished: an HTTP/1.1 connection is
returned to the pool only if its body was read to the end (a partial read closes
it, since unread bytes remain on the wire), and an HTTP/2 stream is reset if
abandoned early. pool.request closes the body on block exit, so a body you never
read still releases its connection rather than stranding it.
Trailers¶
A response can carry trailing headers after its body (gRPC's grpc-status is the
common case). The default path drops them: async for chunk in body and
await body.read() yield only bytes. When you know (out of band, by the
endpoint's interface) that trailers matter, opt in:
data, trailers = await body.read_with_trailers() # trailers: tuple[ResponseTrailers, ...]
# or, while streaming: async for item in body.events(): # bytes | ResponseTrailers
read_with_trailers returns all trailer blocks (an empty tuple if none), so a
consumer that requires them enforces that itself rather than the framework imposing
a failure on every response. Dropping trailers on the default path is a deliberate,
valid choice, not a swallowed error, so a server adding a trailer never breaks a
client that does not ask for it.
Connection pooling¶
ConnectionPool keys connections by origin. HTTP/2 requests to one origin
multiplex over a single pooled connection; HTTP/1.1 connections are kept alive and
reused serially (an idle one is checked out per request and returned once its
response body is read). h2 is negotiated by ALPN over TLS
(ConnectionPool(allow_http2=True), the default; pass a custom ssl_context_factory for a
private CA), or over cleartext by prior knowledge with ConnectionPool(force_http2_cleartext=True)
(no negotiation, so the caller is asserting the server speaks h2c); otherwise the
origin speaks HTTP/1.1.
async with ConnectionPool(allow_http2=True, ssl_context_factory=make_ctx) as pool:
# eight concurrent requests, multiplexed over one h2 connection
bodies = await asyncio.gather(*(fetch(pool, n) for n in range(8)))
Open the pool as an async context manager so its connections are closed on exit; a
directly-constructed ConnectionPool() works for short-lived use but does not manage
the long-lived connections keep-alive retains.
max_connections_per_host bounds the concurrent HTTP/1.1 connections to one origin:
at the bound a checkout waits for one to be returned rather than opening another
(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 you want explicit per-host backpressure. The h2 side has
an intrinsic sibling: stream issuance is gated against the server's advertised
SETTINGS_MAX_CONCURRENT_STREAMS, so a burst never over-issues streams on the one
multiplexed connection.
max_keepalive_per_host bounds a different axis: how many idle HTTP/1.1
connections are retained per origin once a burst subsides. Where the peak cap governs
how high the pool climbs under concurrent load, the idle cap governs how much it holds
onto when quiet: at the cap a returned connection is closed instead of pooled, so the
pool ramps up to max_connections_per_host under load but settles back down to
max_keepalive_per_host afterward rather than leaving 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.
Duplex and bidirectional streaming¶
The request body and the response are handled concurrently: the body is sent by
a background task while the response head and body are read, so a server can answer
before the request body is fully sent. This is what lets a client survive the classic
large-upload deadlock, where a server rejects a big upload early (a 413, a redirect)
and stops reading: the early response is read even though the request-body write is
still backed up on the wire.
Because the request body is a lazy Stream[bytes], this extends to genuine
bidirectional streaming: hand pool.request a queue-backed generator and feed it
in reaction to the response you are reading (the gRPC ping-pong shape).
outbound: asyncio.Queue[bytes | None] = asyncio.Queue()
async def request_body() -> AsyncIterator[bytes]:
while (chunk := await outbound.get()) is not None:
yield chunk
await outbound.put(first_message) # client speaks first
async with pool.request("POST", url, body=request_body()) as (head, body):
async for message in body:
await outbound.put(reply_to(message)) # or None to end the request
The framework provides the mechanism (a concurrent duplex transport); you own the
policy (the interleaving protocol, and the knowledge of the server's interface that
keeps it from deadlocking). It deliberately does not buffer or force the body to
finish first, since that would defeat the pattern. A write/read timeout (below) is
the opt-in safety net that turns a mis-designed interleaving from an eternal hang into
a typed error you chose to arm.
This is genuinely correct over HTTP/2, whose independent per-direction flow control is what bidi is built on. The request head is sent immediately, before the first body chunk is produced, so both a client-speaks-first duplex (send an opening chunk, then feed more in reaction to the response) and a server-speaks-first one (let the server respond before any body chunk is ready) work over one request. Over HTTP/1.1 the same code runs, but real duplex is limited by server and proxy support in the wild; there the concurrency buys the deadlock fix rather than a promise of robust bidi.
Answering early and closing safely has a security dimension on both sides (the
client stops sending on the peer's half-close; the server closes with a bounded
lingering FIN rather than an RST that could discard its own response). See
Security.
Timeouts¶
By default a request has no timeouts: a hung connect or a stalled server blocks
until you cancel it. A timeout is a policy keyed to your time budget ("fail rather
than make slow progress, so my caller can react"), which the transport cannot know,
so you opt in per phase with a Timeout value, on the pool or per request:
from datetime import timedelta
from without_http import Timeout
async with ConnectionPool(timeout=Timeout(connect=timedelta(seconds=10), read=timedelta(seconds=30))) as pool:
async with pool.request("GET", url, timeout=Timeout(read=timedelta(seconds=5))) as (head, body):
...
Each axis is a timedelta, so the unit is explicit rather than an ambiguous bare
number, and an inactivity bound (it re-arms on progress), not a total deadline:
read/write bound the gap between chunks, so a slow-but-progressing transfer is
not killed. Every field defaults to None (that axis disabled), and there is no
shared-default scalar, since one duration across four unrelated phases carries no
meaning. A per-request Timeout replaces the pool's wholesale (it does not layer
through middleware; None inherits the pool default). For an overall wall-clock cap,
compose one on the substrate: async with asyncio.timeout(t): pool.request(...).
What each axis bounds (what is actually happening on the wire; the thing most clients leave you to guess at):
| Axis | Phase it bounds | On the network |
|---|---|---|
connect |
DNS + TCP connect, and (over TLS) the handshake | one open_connection await; ALPN is negotiated here |
write |
making progress sending a request-body chunk | a socket write + drain; over h2, waiting for the flow-control window |
read |
waiting for the next response chunk (head, body, trailers) | a socket read; over h2, the next DATA for this stream |
pool |
acquiring a connection slot | nothing on the wire: the per-host bound or the h2 stream gate |
Over HTTP/2 the read/write axes measure per-stream progress, not socket progress:
a read timeout means "no DATA for my stream in N seconds" even while the
socket is busy with other streams.
What to do when one fires. Each axis raises a typed error under HTTPTimeout
(itself a TimeoutError), so a coarse except TimeoutError catches any while the
specific type tells you how far the request got, which is what determines the safe
recovery:
| Fired | Request got as far as | Safe to retry? |
|---|---|---|
PoolTimeout |
never left the process | always; usually the real fix is local backpressure, not retrying the peer |
ConnectTimeout |
no connection established | always, even a non-idempotent request, or fail over to another origin |
WriteTimeout |
mid-sending the request | idempotent: yes; otherwise ambiguous. The connection is discarded, so a retry gets a fresh one |
ReadTimeout |
request fully sent, awaiting the response | only if idempotent (the server may already have processed it); if mid-body, decide keep-vs-discard the partial |
TCP keepalive¶
Pooled connections outlive the request that opened them, so a kept-alive socket can sit idle for a long time between uses. Two things can end it while it waits, and they need different handling:
- A server cleanly closing its end of an idle keep-alive connection sends a
TCP FIN, which asyncio surfaces on the event loop. The pool notices it before reuse (the checkout skips a connection that is closing or at EOF) and opens a fresh one, so this common case needs nothing from you. - A peer that silently vanishes (a crashed server, a network partition, a NAT or
firewall dropping the flow) sends no
FIN. Nothing surfaces on the event loop, so the dead socket looks reusable until a request stalls on it. With no request timeouts armed (the default), that stall has nothing to bound it.
TCP keepalive closes that second gap: the kernel probes an otherwise-idle connection
and tears it down when the peer stops answering, independent of any request. It is
on by default, as one entry in the pool's socket_options:
from datetime import timedelta
from without_http import ConnectionPool, tcp_keepalive
# The default: probe after 60s idle, every 10s, drop after 6 unanswered probes.
async with ConnectionPool() as pool:
...
# Tune the probe timing, or pass () to leave the kernel's own defaults alone.
async with ConnectionPool(
socket_options=tcp_keepalive(idle=timedelta(seconds=30), interval=timedelta(seconds=5), count=4)
) as pool:
...
idle and interval are timedeltas and MUST be a whole number of seconds (the
underlying options carry only integer seconds, so a sub-second component is rejected
rather than silently truncated); count is a plain probe count. SO_KEEPALIVE is
enabled portably; the per-probe tuning maps to the Linux
TCP_KEEPIDLE/TCP_KEEPINTVL/TCP_KEEPCNT socket options, and a platform that lacks
one of those knobs keeps its own default for that axis.
Socket options¶
tcp_keepalive is not special: it is one of several pure producers of
(level, option, value) triples, and they compose the way headers do. Each describes a
single concern and knows nothing about the others, so combining them is plain
concatenation rather than a merge that has to understand what any of them mean:
from without_http import ConnectionPool, receive_buffer_size, send_buffer_size, serving, tcp_keepalive
async with ConnectionPool(socket_options=tcp_keepalive() + send_buffer_size(1 << 16)) as pool:
...
# On the server, options apply to the *listening* socket.
async with serving(app, socket_options=receive_buffer_size(1 << 16)) as server:
...
The order is the order they are applied in, and passing () sets nothing at all. Note
that keepalive is the pool's default, so a socket_options that should keep probing
has to say so: include tcp_keepalive() in the combined set rather than replacing it.
send_buffer_size and receive_buffer_size pin SO_SNDBUF/SO_RCVBUF, which is how
you make a socket's buffer a known size: left alone, Linux autotunes each up to the
max of net.ipv4.tcp_wmem/tcp_rmem, and
its documentation is the guarantee
being relied on ("Calling setsockopt() with SO_SNDBUF disables automatic tuning of
that socket's send buffer size"). Both are bounds rather than exact reservations: per
socket(7) the value is capped
by net.core.wmem_max/rmem_max, and the kernel stores (and returns) double what you
set, for bookkeeping.
A listening socket hands its buffer sizes down to every connection accepted on it, so
receive_buffer_size on serving bounds what the server will buffer from a peer whose
body it has not read yet. Options that are meaningful only per-connection have nothing
to act on at bind time; TCP_NODELAY is the notable one, and asyncio already sets it on
every TCP transport it creates, in both directions, so there is nothing to configure.
Client middleware¶
A client exchange (ClientRequest -> ClientResponse) is the dual of a server
handler, and a ClientMiddleware wraps one into another: ClientExchange ->
ClientExchange. That is the zero-context case of the same stack that composes
server middleware (a server middleware is (handler, state, scope) -> handler; a
client one needs no context because the request is the value it transforms), so the
one stack serves both. The pool carries a default middleware applied to every
request, and pool.request(..., middleware=...) composes more inside it for a
single call:
from without_http import ConnectionPool, add_headers, follow_redirects, cookies, CookieJar, stack
jar = CookieJar()
async with ConnectionPool(middleware=add_headers((b"authorization", b"Bearer ..."))) as pool:
async with pool.request("GET", url, middleware=stack(follow_redirects(), cookies(jar))) as (head, body):
...
Because the whole request is the value the exchange transforms (not a fixed scope), middleware can rewrite it on the way out (inject headers, change the URL on redirect, attach cookies) and wrap the response on the way back.
For the simple independent case, wrap(request=, response=) builds a middleware from a
request transform and/or a response transform, the client counterpart to
without-asgi's wrap (which wraps a handler's inbound/outbound streams). add_headers
is a one-liner over it: wrap(request=lambda r: replace(r, headers=...)). Reach for it
when the two sides are independent; a middleware whose sides share state (cookies) or
that loops (follow_redirects) is written directly as a ClientExchange wrapper.
from without_http import ClientResponse, wrap
byte_counter = wrap(response=lambda r: ClientResponse(r.head, counting(r.body)))
Keep the pool's own middleware to pure decoration (default headers, redirect
following, retry): things that are values, not state. Anything carrying mutable,
request-spanning identity belongs in a value you own and pass per request. A
CookieJar is the canonical case: you construct the jar and hand it to cookies(jar),
so cookie scope (application identity) stays independent of connection reuse
(transport) rather than both hiding in the pool. Two requests share cookies exactly
when they share a jar. See Cookies for the jar's matching rules, the
origin guards it enforces on untrusted Set-Cookie responses, and its expiry model.