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 | unwritten; a stream transform, #71 | 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) | 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. |
| Server-sent events (#71) | A parser from a byte stream to typed events that composes over any response body, and the encoder that is its mirror on the app side. It needs nothing from the client, so it may not even live in this package. |
| 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. - 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 (file_response); no directory mount (#77), Range, or conditional requests (#70) |
no | no | directory mount below Python (--static-path-mount, --static-path-route) |
| 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 |
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. file_response is a handler helper, so it travels 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. file_response does not.
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.