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). - 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 contract. - 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.
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 contract) 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.
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. Place cookies inside follow_redirects in a stack so each
redirect hop both sends and collects cookies.