http-net 0.9.2

Sans-IO HTTP/1.1 server and client: strict RFC 9112 parsing, smuggling defences, bounded buffers, keep-alive, chunked bodies, upgrades, a blocking std adapter, no third-party dependencies.
Documentation

The model

  • A ServerConn is the server side of one connection. Bytes go in with feed (or read_buf + commit); poll reports a ServerEvent: a request head, body runs, the end of the request, or what it needs next. respond writes a Response into your output buffer; respond_head_only writes just the head, so a large body goes out with it in one vectored write, uncopied.
  • A ClientConn is the mirror image: send_request writes a Request, poll reports the response as **ClientEvent**s.
  • RequestHead and ResponseHead are zero-copy views of a parsed head.
  • Limits bounds every buffer; HttpError says exactly what was refused and which status to answer with, and Response::from_error builds that answer.
  • Both connections carry trailers (reported on request, sent with end_body_with_trailers) and interim responses (send_informational for 103 Early Hints; reported to the client on request), and the client can send Expect: 100-continue and hold its body back. Progress, set_keep_alive, and requests_served give drivers what they need for deadlines and load shedding; DateCache formats Date once per second.
  • Method and MethodSet classify methods (safe, idempotent, a bit set for routers and Allow); reason_phrase and the status constants cover the whole IANA status registry.
  • The client pipelines idempotent requests up to a configured depth; both connections can be reset, pooled, and shrunk while idle, and the server can wipe consumed input so credentials do not linger.
  • Tier 3 for gateways and proxies (the parsers, the decoder, the writer, and copying a head into reused buffers allocate nothing): spans and copied heads for passing a head to another thread, stateless head parsers, a standalone ChunkedDecoder, the Output writer with borrowed fields, forward_from with hop-by-hop removal, and HTTP/2 preface detection.
  • The blocking module (feature std) drives both over std::net: a bounded thread-per-connection Server, a per-connection Connection, and get.

What it guarantees, and how each guarantee is checked:

Guarantee How it is held
Parsing follows RFC 9112; malformed messages get a precise error, never a guess. 17 request accept cases, 67 request reject cases, and 18 response cases as data (tests/rfc_cases.rs), each run whole and one byte at a time; a differential test against an independent whole-buffer reference parser over every insertion or substitution of 23 boundary bytes and every deletion, at every position of eight seed requests, plus 8,192 random mutations of one to three edits (tests/prop_reference.rs).
Request smuggling vectors are refused. 25 CL.TE / TE.CL / TE.TE / line-ending vectors and 7 chunk-extension line-end vectors (the Funky Chunks classes), each at every split point (tests/smuggling.rs); after an error the connection never parses another request; outgoing messages cannot carry framing headers or CR/LF. An adversarial suite (tests/adversarial.rs) adds target forms and the authority, Host escapes, Expect: 100-continue and Expect-based desync shapes, 0.CL desync, h2c and unoffered upgrades, chunk and trailer edges, cumulative chunk-extension and interim-response budgets, and integer edges.
Limits are enforced before allocation. Boundary tests at the limit and one past it for line length, header bytes, header count, and body; declared lengths and chunk sizes are checked before their data is read; the input buffer never exceeds Limits::buffer_capacity(): head + max(head, max_trailer_bytes) + 16 KiB, where head = max_start_line + max_header_bytes (64 KiB with the defaults), and the test drivers assert the parser never waits for input with a full buffer.
Nothing panics on hostile bytes. Property tests feed arbitrary bytes and dictionary-built near-HTTP to both connections, from the first byte and behind valid heads (so body, chunk, and trailer states are reached), whole and split, with default and tiny limits.
The parse does not depend on how input is split. Property tests compare whole, split, and byte-at-a-time feeding.
Serialize and parse round-trip. Property tests: requests from ClientConn parsed by ServerConn, responses the other way, with full, chunked, and fixed-length streamed bodies; absolute-form targets; keep-alive intent.
Slow or stuck peers cannot pin the server. Socket tests: a head or body trickled inside the read timeout is cut off by the request timeout with 408; a refused client that keeps sending cannot stall the accept loop; lingering close ends after 1 s however the peer behaves.
Credentials never reach logs through Debug. authorization, proxy-authorization, cookie, and set-cookie values print as <redacted N bytes> in every Debug output of the crate, and body and tunnel bytes print as their length; tests/redaction.rs checks every type that prints fields, in every case spelling of the names.
Steady-state parsing allocates nothing. A counting allocator (tests/alloc.rs): 10,000 keep-alive requests on one ServerConn, and 10,000 responses (also pipelined) on one ClientConn, allocate nothing after warm-up; the stateless parsers and the ChunkedDecoder allocate nothing at all, and the Output writer allocates nothing into a reused buffer; 10 MiB hostile streams never grow a connection past its buffer capacity.
It agrees with other parsers where it accepts. A differential test against httparse (tests/prop_httparse.rs): on every request head both accept, the length, method, target, version, and fields match; every head only httparse accepts is one of the documented stricter refusals. An RFC 9112 / 9110 conformance table (tests/conformance.rs) checks one statement per row.
It survives fuzzing. Eight cargo-fuzz targets (fuzz/): both connections under fuzzer-chosen splits, limits, and options (client fold leniency included), the chunk-line grammar, the builders, the stateless parsers and the ChunkedDecoder against the connections, and differential oracles against the reference parser and httparse, each run for 60 seconds in CI on every push and pull request to main and dev.
It works over real sockets. tests/localhost.rs: the Tier-1 server and client, keep-alive, pipelining, a 200 KB chunked upload echoed back, streamed responses, error answers, Expect: 100-continue, the connection cap, the upgrade hand-off, and shutdown.

Installation

[dependencies]
http-net = "0.9"

Without the standard library (the sans-IO core only):

[dependencies]
http-net = { version = "0.9", default-features = false }

Quick start

Serve a handler on localhost and fetch from it:

use http_net::{blocking, Response};

let server = blocking::Server::bind("127.0.0.1:0")?;
let addr = server.local_addr();
let stop = server.shutdown_handle();
let thread = std::thread::spawn(move || {
    server.serve(|req, _body| Response::text(200, &format!("you asked for {}", req.path())))
});

let resp = blocking::get(&format!("http://{addr}/hello"))?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.text(), Some("you asked for /hello"));

stop.shutdown();
thread.join().unwrap()?;
# Ok::<(), Box<dyn std::error::Error>>(())

The server runs one thread per connection, at most 64 at once (the next is answered 503), with a 10 s read timeout and a 30 s deadline per request (first byte to end of body; a late request gets 408), answers Expect: 100-continue itself, and adds a date header to every response except that fixed 503. Requests that fail to parse are answered with the matching status (400, 413, 414, 431, 501, 505) and closed; the handler never sees them. A handler that returns a streamed or invalid response gets a 500 instead of a hung client.

The sans-IO core

The same kind of exchange with no socket at all:

use http_net::{Response, ServerConn, ServerEvent};

let mut conn = ServerConn::new();
let mut out = Vec::new();
let _ = conn.feed(b"POST /notes HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\nhello");
let mut body = Vec::new();
loop {
    match conn.poll()? {
        ServerEvent::Request(req) => assert_eq!(req.path(), "/notes"),
        ServerEvent::Body(chunk) => body.extend_from_slice(chunk),
        ServerEvent::RequestEnd => conn.respond(&Response::text(201, "saved"), &mut out)?,
        _ => break, // NeedInput: read more from the socket and feed it
    }
}
assert_eq!(body, b"hello");
assert!(out.starts_with(b"HTTP/1.1 201 Created\r\n"));
# Ok::<(), http_net::HttpError>(())

Handing a connection to WebSocket

use http_net::{Response, ServerConn, ServerEvent};

let mut conn = ServerConn::new();
let _ = conn.feed(b"GET /rpc HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n\x81\x85");
let ServerEvent::Request(req) = conn.poll()? else { unreachable!() };
assert!(req.is_upgrade());
assert!(matches!(conn.poll()?, ServerEvent::RequestEnd));

let mut out = Vec::new();
let accept = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; // computed by the WebSocket engine
conn.respond(&Response::switching_protocols("websocket").header("sec-websocket-accept", accept), &mut out)?;
assert!(matches!(conn.poll()?, ServerEvent::Upgraded));
assert_eq!(conn.remaining_input(), b"\x81\x85"); // the first bytes of the first frame
# Ok::<(), http_net::HttpError>(())

Limits

Budget Default Error when exceeded
request line, status line, chunk-size line 8 KiB StartLineTooLong (414) / ChunkLineTooLong (400)
header section 16 KiB HeadersTooLarge (431)
one field line (headers and trailers) 8 KiB FieldLineTooLong (431)
header fields 100 TooManyHeaders (431)
trailer section and trailer fields the header budgets, unless set HeadersTooLarge / TooManyHeaders
body 1 MiB BodyTooLarge (413)
chunk extensions, summed over one message 16 KiB (the header budget) ChunkExtensionsTooLarge (400)
interim 1xx responses before a final one (client) 16, and 16 KiB of heads together TooManyInterim / HeadersTooLarge

These are the budgets of LexerSketch Studio's localhost server. Change them with Limits. The blocking server adds time budgets in ServerConfig: 10 s per read, 30 s per request, 10 s per write, 60 s to write a whole response, at most 1 s of lingering close.

Examples

cargo run --example localhost   # serve, fetch, shut down
cargo run --example sans_io     # the core with no socket
cargo run --example early_hints # 103 Early Hints, server to client, in memory
cargo run --example trailers    # trailer fields both ways, in memory
cargo run --example pipelined_client # four requests in flight, answered in order
cargo run --example passport    # copy heads out and hand them to a worker thread
cargo run --example proxy_forward    # forward a request upstream without hop-by-hop fields
cargo run --example serve -- 127.0.0.1:8080
cargo run --example get -- http://127.0.0.1:8080/
cargo run --release --example soak -- 90s 4  # mixed workload for a set time, client threads

What is not here yet

  • TLS: plain http:// only.
  • 1.0.0 itself: fixes only in v0.9.x, then 1.0 after the audit in docs/STABILITY-1.0.md. See the roadmap.
  • An async runtime or adapter: never; drive the sans-IO core from any loop.
  • Content codings (gzip and others): a body is delivered as sent. A transfer coding before chunked is refused with 501 (UnsupportedTransferEncoding); a Transfer-Encoding that does not end in a single chunked is refused with 400 (InvalidTransferEncoding).

Performance

Criterion medians for 0.6.0 in WSL Ubuntu on a Windows x86_64 machine (AMD Ryzen 9 9950X3D, Rust stable, release profile), from cargo bench pinned to one core, run alternately with a 0.5.0 build so machine load falls on both. Method, Windows numbers, and every decision behind them: docs/PERFORMANCE.md. 0.7.0 and 0.8.0 were measured against their predecessors with no measurable difference (docs/release/v0.8.0.md), and 0.9.1 against every release from 0.6.0 with no regression, so these 0.6.0 numbers stand.

Benchmark 0.6.0 vs 0.5.0
12-field browser GET, parsed and answered ~234 ns -23%
100,000 pipelined GETs in 16 KiB reads ~8.8 ms (11.4 M requests/s) -13%
1 MiB body, Content-Length, 16 KiB reads ~13.5 µs -4%
1 MiB body, chunked in 4 KiB chunks ~17.0 µs -3%
response with five fields and a 1 KiB body, serialized (after reading the one-field request it answers, about 62 ns of it) ~74 ns -17%
client parsing a 1 KiB response ~100 ns -11%

Against other libraries, on identical bytes (http-net-bench): a full server exchange takes ~250 ns against hyper's ~1.4 µs (3.4 to 5.5x faster), 100,000 pipelined requests 7.3 ms against hyper's 23.6 ms, and a client exchange 95 ns against hyper's 1.24 µs. Parsing a head alone is level with httparse (~177 ns against ~172 ns with httparse's SIMD) while also validating the Host value, the target, every value byte, and the framing rules, with no unsafe code.

Feature by feature against hyper, httparse, h11, llhttp, picohttpparser, and the http crate, each cell checked: docs/PARITY.md.

These are library-only numbers: no sockets are involved. Body events borrow the input buffer, so the 1 MiB cases measure one copy into the buffer plus framing.

Contributing

See dev/DIRECTIVES.md for engineering standards and the definition of done. Before a PR: cargo fmt --all, cargo clippy --all-targets --all-features -- -D warnings, and cargo test --all-features must be clean.