Alternatives¶
This page is a feature-by-feature register of where without-http stands
against the clients people usually reach for:
httpx, aiohttp,
and niquests, and of the server against
the ASGI servers: uvicorn,
hypercorn, and
granian. It is maintained by
hand against each project's own documentation, and it is a roadmap as much as a
comparison. In the without-http column, unwritten marks a gap whose fix is
a composition against an interface that already ships (a middleware, a
Connect, a stream transform), with the cell linking to its shape in
how a gap closes; a bare no there is either genuine
new mechanism or a position taken deliberately
(what stays absent); and declined marks a capability
this project has decided against, with the reasoning on the linked issue.
How to read the cells: yes and no record what each project's
documentation, issue tracker, changelog, or source says, and cells link their
source so the analysis is reproducible; a dash means none of those settled it,
and the page stops at what can be cited. Third-party add-ons are named where
they are the well-known answer. A bare issue number in the without-http
column is this project's own tracker: an open one carries the design
constraints already settled for that gap, and a closed one records a position
taken and why.
The structural difference to keep in mind while reading is where each library
puts policy. httpx, aiohttp, and niquests are client objects: redirects,
cookies, auth, and timeouts are constructor flags, and the pool, the policy,
and the API arrive as one artifact. Here a client is one function type,
ClientRequest -> Awaitable[ClientResponse]; the pool is one implementation of
it, and every policy is a middleware that maps a client to a client. So several
rows below that read as missing features are really missing compositions:
the mechanism exists and nobody has written the ten-line middleware yet. The
honest flip side is that several others (a WebSocket client, HTTP/3) are real
mechanisms this package does not have.
The client¶
Protocols¶
| without-http | httpx | aiohttp | niquests | |
|---|---|---|---|---|
| HTTP/1.1 | yes | yes | yes | yes |
| HTTP/2 | yes, on by default (ALPN) | opt-in (http2=True, optional extra) |
no | yes |
| HTTP/2 cleartext, prior knowledge | yes (force_http2_cleartext) |
yes (http1=False, http2=True) |
no | yes |
| HTTP/3 (QUIC) | no; new mechanism, #11 | no | no | yes |
| WebSocket client | no; new mechanism, #69 | no | yes | yes, including over HTTP/2 and HTTP/3 |
| WebSockets over HTTP/2 (RFC 8441 extended CONNECT) | no, client or server; #21 | no WebSocket client | no HTTP/2 | yes |
| Server-sent events | yes; the format is a pure stream transform in without-asgi, and subscribe here reconnects and resumes |
third party (httpx-sse) |
third party (aiohttp-sse-client) |
yes |
Connections¶
| without-http | httpx | aiohttp | niquests | |
|---|---|---|---|---|
| Pooling with keep-alive | yes, keyed by origin | yes | yes | yes |
| Default pool bounds | unbounded per host, by stated position | 100 total, 20 keep-alive | 100 total, no per-host limit | 10 hosts, 10 per host (requests defaults) |
| TCP keepalive probing | on by default | off; socket_options on the transport |
— | protocol pings for h2/h3 |
| Happy Eyeballs (dual-stack connect) | on by default (250 ms, via aiohappyeyeballs; tcp_connect() tunes it) |
no | yes | yes |
| DNS caching or custom resolution | resolution injectable (tcp_connect(resolve=)); a cache is unwritten, a Resolve wrapper, #75 |
— | cache with TTL | DoH, DoT, DoQ, DNSSEC, custom resolvers |
| Unix domain sockets | unwritten; a Connect, #72 |
yes (uds=) |
yes (UnixConnector) |
— |
| Proxies | unwritten; a Connect for the CONNECT tunnel, #73 |
HTTP(S); SOCKS via extra | HTTP; SOCKS third party | HTTP(S) and SOCKS |
Requests and responses¶
| without-http | httpx | aiohttp | niquests | |
|---|---|---|---|---|
| Streaming bodies, both directions | yes | yes | yes | yes |
| Concurrent duplex (early responses, bidi) | yes, full bidi | no, request sent fully first | no; WebSockets are the duplex path | early responses |
| Response trailers | yes | no | no public API | yes |
| Response decompression | yes, opt-in (decompress() middleware: gzip, zstd, brotli; injectable coding table) |
yes (gzip; brotli/zstd extras) | yes (auto_decompress) |
yes |
| Request compression | yes, opt-in (gzip_compress()/zstd_compress()/brotli_compress() middleware, per client or per call) |
— | yes (compress=, deflate, off by default) |
— |
| Multipart and form encoding | yes (form_content, multipart_content; multipart streams file parts) |
yes | yes | yes |
| Headers sent unbidden | none (only host and body framing) |
user-agent, accept, accept-encoding | user-agent and friends | requests-compatible set |
Policy¶
| without-http | httpx | aiohttp | niquests | |
|---|---|---|---|---|
| Timeouts | opt-in, per phase, inactivity-based, typed errors | 5 s default across four phases | 5 min total default | on by default, per method (30 s reads, 120 s writes) |
| Redirects | opt-in middleware (follow_redirects()) |
built-in, off by default | built-in, on by default | built-in, on by default |
| Cookies | explicit jar, attached by middleware | jar on the client | jar on the session | jar on the session |
| Auth helpers | Basic and bearer (basic_auth, bearer_auth); Digest unwritten, middleware-shaped, #74 |
Basic and Digest | Basic | Basic and Digest |
| Retries | no, by position (mechanism ships, policy stays with the caller; the one loop that ships is SSE reconnection, whose policy is on the wire) | connect phase only (HTTPTransport(retries=)) |
third party (aiohttp-retry) |
off by default; first-class retries= (urllib3 Retry) |
| Extension mechanism | function composition over Client |
event hooks, custom transports | client middlewares | requests-style hooks |
| Certificate revocation, OS truststore | no (inject ssl_context_factory) |
— | — | OCSP, CRL, OS truststore |
| Synchronous API | no, by position | yes | no | yes |
Testing¶
| without-http | httpx | aiohttp | niquests | |
|---|---|---|---|---|
| Canned responses | mock_client |
MockTransport |
third party (aioresponses) |
— |
| Drive an app in memory | asgi_client |
ASGITransport, WSGITransport |
test server on a real socket | — |
| Real wire protocols, no socket | loopback_client, pipe |
— | — | — |
The last row is the one nothing else offers: the full h11/h2/wsproto stack over an in-memory pipe, so a test exercises real framing without a port. See Testing for what each in-memory client covers and what none can.
How a gap closes here¶
Every unwritten cell above has a known shape, because the interface it plugs
into already exists. That is the claim this page exists to test, so it is
worth being specific:
| Gap | The shape it takes |
|---|---|
| Digest auth (#74) | A looping middleware with the same shape as follow_redirects, since Digest answers a challenge. The challenge-free schemes (basic_auth, bearer_auth) need no loop and ship. |
| Proxies (#73), Unix sockets (#72), local address | Connect implementations. The pool takes Connect at construction; none of these are written, but none needs a new interface (tcp_connect is the shape, already shipped). The proxy case has one edge that is not a Connect: a cleartext forward proxy wants the absolute URI in the request line, and the pool derives that target from the URL, so only the CONNECT tunnel fits the seam. |
| DNS caching (#75) | A Resolve wrapper owning the cache, injected as tcp_connect(resolve=...), so resolution policy stays with the caller instead of inside the pool. The interface ships; the cache does not, because getaddrinfo hides record TTLs, making any staleness bound the caller's policy to choose. |
| WebSocket client (#69) | A real addition: a new rim API over wsproto. The server-side wire mapping (ws_wire) exists; the client half and its API shape do not. Carrying WebSockets over HTTP/2 (#21) is a second, separable addition: wsproto's high-level connection is welded to the h11 handshake, so both halves would drive frame_protocol directly. |
| HTTP/3 (#11) | A new wire module over a QUIC implementation, following the sans-IO pattern h11_wire/h2_wire set. The largest item on this page, and the one whose constraints are most settled: an optional aioquic extra, TLS-only, opt-in through the existing serving entrypoint alongside the TCP listener, with Alt-Svc injection gated on this process actually terminating h3. |
The pattern in that column is the point. In a client object, each of these is a
feature request against the object; here each is a middleware, a Connect
implementation, or a stream transform, written against an interface that
already ships. The two that break the pattern (a WebSocket client, HTTP/3) are
honestly new mechanism, and they are the expensive ones.
What stays absent¶
Positions, not gaps, each with its cost named:
- No synchronous API. httpx and niquests serve scripts and the REPL
directly; here that audience pays an
asyncio.runwrapper. The singleClientfunction type is the thing being protected: a sync twin would be a second surface for every middleware. - No default timeouts. A hung peer blocks until cancelled unless a
Timeoutis armed. The budget is the caller's policy (Timeouts); a transport-chosen default is a decision taken from them. httpx's 5 s default is the opposite position, and it is the friendlier one on day one. - No headers sent unbidden. No user-agent, no
accept-encoding. Requests say exactly what the caller said; the cost is that peers which vary on user-agent see an empty one until you add it, and some (the GitHub API) refuse the request outright. Composing thedecompressmiddleware is how a client opts into anaccept-encodingoffer, and composinguser_agent()is how it opts into an identity (the library's ownwithout-http/<version>when given no segments), which is the position holding, not an exception to it. - No retry middleware. The mechanism for a safe caller-side retry ships;
the policy stays with the caller. Errors are typed per phase
(
ConnectTimeoutvsReadTimeout, so a loop can retry a failed connect and not a half-read response), theTimeoutbudget rides on the request value so an attempt can shorten it,Contentbodies are replayable values, and the pool checks liveness before reusing an idle connection, preventing the common stale keep-alive failure rather than hiding a replay. A shippedretry()would add only policy (attempts, backoff, which statuses,Retry-After), which the caller and the layers above (without-durabilityre-runs steps) hold more context for, and which grows a predicate or a flag per new user. The costs: a caller who wants retries writes the loop, including draining the failed response so its connection is released; and the check-then-use race on a kept-alive connection can still surface an error other clients hide by silently replaying idempotent requests. If that race shows up in practice it is a pool concern to fix there, not a middleware.
subscribe is the one shipped loop, and it is
this position holding rather than an exception to it. What the position rejects
is policy the library would have to invent; an event stream carries its own.
The backoff arrives on the wire as retry:, the resumption token as id:, and
the terminal condition (a non-200, or a content type that is not
text/event-stream) is written into the protocol, which is exactly what a
general retry() could not say. Its two settings bound how far the peer
supplying that backoff is trusted.
- Unbounded per-host connections by default. Mirrors the server's choice to
let OS backpressure govern (Connection pooling);
the cost is that a runaway caller opens sockets until the OS objects, where
httpx would have queued at 100.
The server¶
Read this half with one fact in front: the ASGI boundary already did the work
this page exists to check. A without app is a plain ASGI app, so the server
is swappable wholesale, and for raw HTTP/1.1 throughput the obvious choice is
uvicorn or granian, where speed is the whole game and a pure-Python server
should not pretend otherwise. What serving here earns its keep on is protocol
coverage (HTTP/2 end-to-end, including cleartext prior knowledge, which
uvicorn refuses), typed per-connection bounds, and served_pipe, which runs
this exact server over an in-memory pipe so wire-level tests need no port.
aiohttp's server is absent from the tables for the same reason it is instructive: it is a framework with its own application type, not an ASGI server, so nothing written for it travels. The narrow waist is what makes this whole column swappable at all.
Protocols and applications¶
| without-http | uvicorn | hypercorn | granian | |
|---|---|---|---|---|
| HTTP/1.1 | yes | yes (httptools or h11) |
yes | yes |
| HTTP/2 | yes | no | yes | yes |
| HTTP/2 cleartext, prior knowledge | yes (preface sniffed) | no | upgrade only (no upgrade or ALPN reads as HTTP/1.1) | undocumented (--http 2 reportedly serves it) |
| HTTP/3 (QUIC) | no; #11 | no | optional (hypercorn[h3], aioquic) |
no |
| WebSockets | over HTTP/1.1 only; HTTP/2 is #21 | yes (websockets or wsproto) |
over HTTP/1 and HTTP/2 | yes |
| ASGI lifespan | yes, with the no-lifespan fallback | yes | yes | yes |
| WSGI apps | no, by position (async only) | — | yes | yes |
| Other app interfaces | any ASGI app | any ASGI app | any ASGI or WSGI app | RSGI (its native interface) |
ASGI extensions¶
The ASGI extensions are the sharpest per-server comparison, because each is a named optional capability a server either implements or does not.
| Extension | without-http | uvicorn | hypercorn | granian |
|---|---|---|---|---|
websocket.http.response (denial response) |
yes (ws_wire) |
yes | yes | yes |
http.response.early_hint (103) |
yes, both protocols | no | yes | pending |
http.response.trailers |
no (raises); #16 | — | yes | pending |
http.response.push (HTTP/2 server push) |
no; #10 | no HTTP/2 | yes | no, blocked upstream |
http.response.pathsend |
declined (nothing below Python to hand the transfer to) | — | — | yes |
http.response.zerocopysend |
declined (same, plus loop.sendfile breaks h11's framing) |
— | — | declined (Rust/Python fd sharing) |
http.response.debug |
declined; the spec says servers should not implement it | — | — | — |
tls |
yes, on every TLS scope (tls_extension); server_cert and cipher_suite are None, which CPython's ssl cannot supply |
— | — | tracked |
Two notes on the without-http column. First, the scopes advertise exactly
what the wire layers implement (http.response.early_hint on HTTP scopes,
websocket.http.response on WebSocket scopes, tls on both when the
connection is over TLS, and the in-memory asgi_client adds
http.response.trailers, the one extension memory can honor that the
wire cannot), so a third-party ASGI framework that checks the scope before
using an extension, as the spec tells it to, finds them; a without-asgi app
can speak the typed vocabulary directly without checking. Advertising follows
what the request can use, not just what the wire layer can render: an
HTTP/1.0 request gets no http.response.early_hint, since
RFC 8297 forbids a
103 to a client that would read it as the final response. Second, the no
cells are loud rather than silent: without-asgi types every one of these
messages, and the wire layers raise NotImplementedError on the unsupported
ones instead of dropping them.
The three declined cells are a position rather than a queue item, and the
reasoning generalizes past this project. The offload extensions exist to get
file bytes out of the Python layer, and a pure-Python asyncio server has no
lower layer to hand them to: loop.sendfile is the one true kernel path and it
bypasses h11's content-length accounting, which is the same framing conflict
that ended uvicorn's zero-copy attempt,
while granian's shipped pathsend is a chunked read-and-stream rather than the
syscall. A read-and-frame implementation here would be no faster than the app
streaming the body itself, which chunked http.response.body already does, so
the extension would offload only the byte-copy loop, exactly the part pure
Python cannot offload. That leaves the app to stat and sniff for its own
content-type and content-length anyway; without-asgi's
file_response is the composition
that does it.
Operations¶
| without-http | uvicorn | hypercorn | granian | |
|---|---|---|---|---|
| Multiprocess workers | no, by position (a process manager's job) | --workers |
workers |
--workers, plus Rust runtime threads |
| Worker lifecycle (max requests, respawn) | no, by position | --limit-max-requests (+ jitter) |
max_requests |
lifetime, max-RSS, respawn on failure |
| Auto-reload for development | no, by position (restart the process) | --reload |
use_reloader |
--reload (extra) |
| Event loop choice | caller's (asyncio.Runner(loop_factory=...), e.g. uvloop) |
--loop (uvloop) |
worker_class (asyncio, uvloop, trio) |
--loop (asyncio, rloop, uvloop, winloop) |
| TLS | any ssl.SSLContext; server_ssl_context helper with ALPN |
cert/key flags | certfile/keyfile |
cert/key flags, TLS 1.3 default |
| Client certificates (mTLS) | via your ssl.SSLContext |
--ssl-cert-reqs, --ssl-ca-certs |
verify_mode, ca_certs |
--ssl-client-verify, --ssl-ca, CRLs |
Proxy headers (x-forwarded-*) |
unwritten; ASGI-middleware-shaped, #76 | on by default, trusted-IP gated | ProxyFixMiddleware, shipped as ASGI middleware |
— |
| Access logging | no, by position (an ASGI middleware the caller writes) | yes | accesslog + format |
opt-in, custom format |
| Static file serving | app-side: a startup inventory rather than a directory mount, with Range, conditional requests, and pre-compressed variants |
no | no | directory mount below Python (--static-path-mount, --static-path-route) |
| Response compression | app-side (compress() middleware: brotli, zstd, and gzip, injectable coding table, RFC 9110 weights, Heal The Breach padding opt-in) |
no; app-side (Starlette's GZipMiddleware, gzip only) |
no; app-side | no; app-side (precompressed static files in flight) |
| Metrics | live in_flight count on the Server value; no exporter, by position |
no | — | Prometheus exporter |
| Graceful shutdown | drain on exit; budget composed by the caller | --timeout-graceful-shutdown |
graceful_timeout |
kill timeout |
The compression row is the one where every column agrees, and the agreement is
worth reading rather than skipping. No ASGI server compresses responses, and
none should: the ASGI spec defines no extension for it, the decision needs the
response's media type and the request's accept-encoding rather than anything
about the socket, and coverage wants to be scoped per route. It is middleware
everywhere, which is why compress() lives in without-asgi beside
limit_request_body rather than in the server here.
What differs is how much of RFC 9110 §12.5.3 the middleware implements, and
the ecosystem answer is "the first line of it". Starlette, Django, BlackSheep,
and aiohttp all decide by substring or word match on the raw header, so
gzip;q=0 selects gzip in every one of them, identity;q=0 is ignored, and
weights never rank anything; aiohttp picks in ContentCoding declaration order,
so a browser offering gzip, deflate, br is answered in deflate.
Vary: Accept-Encoding is the other common gap: Django patches it
unconditionally, Starlette omits it on responses below its size threshold, and
BlackSheep never sets it. Django is also the only one of the four to mitigate
BREACH, through the Heal The Breach padding its max_random_bytes controls;
PADDED_COMPRESSORS is the same mitigation here, extended to zstd's skippable
frames alongside gzip's filename field. negotiate_coding is the whole section
instead, weights included, as a pure function that can be read and tested apart
from the middleware that calls it.
Limits and robustness¶
| without-http | uvicorn | hypercorn | granian | |
|---|---|---|---|---|
| Accept backlog | max_pending_connections |
--backlog (2048) |
backlog (100) |
--backlog (1024) |
| Concurrency limit with shedding | limit_concurrent_requests app middleware (503); no transport-level shed for third-party apps, #20 |
--limit-concurrency (503) |
— | --backpressure (pauses accept loop) |
| Idle / keep-alive timeout | idle_timeout, off by default, also bounds slowloris and idle WebSockets |
--timeout-keep-alive (5 s) |
keep_alive_timeout (5 s) |
HTTP/2 keep-alive tuning |
| Drain bound on connection close | close_timeout (5 s), per connection and on every close: past it the transport is aborted, so a peer that stops reading cannot hold a descriptor or a shutdown |
only shutdown-scoped (--timeout-graceful-shutdown, unset by default) |
only shutdown-scoped (graceful_timeout) |
only process-scoped (--workers-kill-timeout, off by default) |
| Request body cap | limit_request_body app middleware (413) |
— | — | — |
| Request line + header cap | max_incomplete_event_bytes (HTTP/1.1, 16 KiB) and max_header_list_bytes (HTTP/2, 64 KiB) |
--h11-max-incomplete-event-size |
h11_max_incomplete_size |
header size options |
| HTTP/2 stream and frame tuning | max_concurrent_streams, max_header_list_bytes, flow-control-bounded body buffering |
no HTTP/2 | stream, header-list, and frame size options | window, stream, and frame size options |
| Rapid Reset mitigation (CVE-2023-44487) | max_stream_resets, reset cancels the app task |
no HTTP/2 | — | — |
| WebSocket message cap | max_websocket_message_bytes |
--ws-max-size (16 MB) |
websocket_max_message_size (16 MiB) |
— |
Two readings of these tables coexist. As a deployment checklist, the
operations table is the honest one: workers, reload, proxy headers, and access
logs are what a production rollout has to bring, and here they belong to the
process manager and the composition root rather than to the server
(what the server leaves out), which is a
position the deployment still has to staff. As a boundary-design comparison,
the limits table is the interesting one: the other servers put shedding and
body caps in server configuration, where they exist once per server
implementation; here limit_concurrent_requests and limit_request_body are
app-side middleware, written once against the ASGI boundary and carried to any
server underneath, including uvicorn. That is the layering argument of this
whole stack in one row: a limit that is a server flag protects only apps on
that server, while a limit that is a composition travels with the app.
hypercorn takes the same position for proxy headers, shipping
ProxyFixMiddleware as ASGI middleware rather than a server flag, which is
the position working from the other direction.
The argument has one edge, which the shedding row names. A limit that travels
with the app reaches only apps you compose, so hosting a third-party ASGI app
(a FastAPI or Starlette app handed to serving whole) leaves nothing to inject
the middleware into, and uvicorn's transport-level --limit-concurrency covers
a case this does not
(#20). That is the price of
the position, payable when protecting a hosted app matters.
Static file serving sits on the same fault line, which is why its cell is split
rather than a no. These are handler helpers, so they travel to any server and
can turn a missing file into a clean 404 before a status is on the wire;
granian's mount serves the directory below Python without entering the app at
all, which no composition here can match, and its docs do not settle whether it
handles Range or conditional requests.
The interesting divergence is not the layer, though, it is the shape. Every
server in this table mounts a directory and derives a path from the request,
which is the construction that has produced a long line of traversal advisories.
An Inventory walks
the tree once at startup instead, so the request key selects among precomputed
values and no path is derived at all. That buys a shorter request path as well
as a smaller security surface: a revalidation answers a 304 from memory with
no syscall, and pre-compressed variants held as bytes make a Range over a
compressed asset work, which on-the-fly compression cannot do at any layer. The
price is the one in the name: the tree must not change while the process runs,
and a development loop rebuilds the inventory rather than picking up edits.
The remaining unwritten cell in the operations table is the server-side mirror
of the client's unwritten compositions: a proxy-header middleware over the ASGI
boundary (#76) closes that
row generically, for any server underneath, and is the next generic middleware
to ship. Access logging is the same shape and is not shipping, for the reason
below.
What the server leaves out¶
Positions, not gaps, each with its cost named. The client half has its own list (what stays absent); these are the server's.
- No multiprocess workers, and no worker lifecycle. One
servingcall is one process. Running N of them, restarting one that dies, recycling one that has served enough requests or grown too large: that is a process manager's job, and gunicorn already does all of it, decoupled from the server it supervises. The cost is that a deployment brings its own supervisor rather than passing--workers, and that scaling is one more moving piece to operate. What is bought is that the server does not reimplement process supervision badly, and that the supervisor is replaceable. - No auto-reload. Restart the whole process on a change, with watchfiles or whatever the editor already runs. Hot reloading a running interpreter means partial module reloads, stale closures, and lifespan state that survives a change it should not; the failure mode is a developer debugging a ghost. The cost is startup latency on every edit, and the position is that startup is fast enough. If it is not, that is a startup-time problem to fix rather than a reason to reload in place.
- No WSGI. This stack is async to the socket, and a WSGI app is synchronous, so hosting one means a thread pool, a second concurrency model, and a set of semantics (no streaming request bodies, no WebSockets, no lifespan) that the rest of the vocabulary does not have. The cost is that a Flask or Django-sync app needs its own server, or an adapter the caller chooses.
- No metrics exporter. The position is to expose what a metric would be
computed from, as live values on the objects that own them, and let the
caller emit whatever their telemetry stack wants.
Server.in_flightis the first of those; more (request counts, byte totals) join it as the server grows. The cost is that there is no--metricsflag to turn on, and a deployment writes the few lines that read the value on a timer. What is bought is that no telemetry library is baked into the server, and the same values feed Prometheus, OpenTelemetry, a log line, or a test assertion. - No access logging. An access-log middleware over the ASGI boundary is a
composition any caller can write against the scope and the response, and what
belongs in the line (which headers, which correlation ID, what redaction,
which format) is deployment policy that a shipped default would have to grow
a flag per opinion to serve.
without-loggingis the natural place to assemble the record. The cost is real, though: a caller who wants the ordinary combined-log line writes it themselves, and every project writes it slightly differently. The proxy-header middleware is the deliberate contrast, and the difference is the failure mode: a slightly wrong log line is a slightly wrong log line, while a proxy-header middleware without a trust gate is a spoofable client address.