# http-net v0.4.0 - Extended
**Date:** 2026-10-21
**Compare:** `v0.3.0...v0.4.0`
**Trailers, interim responses, and `Expect` in both directions, and what
drivers need to run a connection.** v0.4.0 completes the protocol features
the spec's parity review found missing next to hyper and h11: trailer fields
are reported and sent on both sides, a server can send `103 Early Hints`
and a client can see it, and a client can send `Expect: 100-continue` and
hold its body back until the server agrees. Drivers (serve-net, fetch-net,
WASP's gateway) get keep-alive control, a request count, and a `Progress`
phase for their deadlines, plus a `Date` value formatted once per second.
New budgets cap one field line, the trailer section, and the number of
interim responses. The blocking adapter gains total deadlines, a request
cap, `417` for unknown expectations, and one-call `serve` and `post`. There
are still no dependencies.
## What is http-net?
An HTTP/1.1 engine that does no I/O. Feed it the bytes a socket delivered;
it hands back requests or responses as borrowed views of those bytes,
decodes their bodies, and serializes what you send back. Parsing is strict:
a message two implementations could frame differently is refused with a
specific error, never repaired. Every buffer is bounded by configuration,
and each limit is checked before the bytes it governs are buffered.
## What changed since v0.3.0
### New - trailers both ways
`set_report_trailers(true)` on either connection keeps a message's trailer
fields and reports them as `ServerEvent::Trailers` / `ClientEvent::Trailers`
just before the end of the message (only when the section is not empty).
By default they are validated and discarded, as before. Trailers never
change how a message is framed. `end_body_with_trailers` sends a trailer
section after a chunked body, on both sides. Every field is validated
before anything is written, and the fields RFC 9110 section 6.5.1 keeps out
of trailers (framing, routing, authentication, request modifiers, response
control, content handling: 33 names) are refused with the new
`TrailerNotAllowed`, as are trailers on a `content_length` body. When an
HTTP/1.0 client forced a close-delimited body, or the request was `HEAD`,
the trailers are dropped with the body and the call still succeeds.
```rust
use http_net::{Response, ServerConn, ServerEvent};
let mut conn = ServerConn::new();
conn.set_report_trailers(true);
let _ = conn.feed(b"POST /up HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nhi\r\n0\r\nDigest: sha-256=x\r\n\r\n");
let mut digest = None;
loop {
match conn.poll()? {
ServerEvent::Trailers(t) => digest = t.clone().next().map(|(_, v)| v.to_vec()),
ServerEvent::RequestEnd => break,
_ => {}
}
}
assert_eq!(digest.as_deref(), Some(&b"sha-256=x"[..]));
let mut out = Vec::new();
conn.respond(&Response::new(200).chunked(), &mut out)?;
conn.send_body(b"stored", &mut out)?;
conn.end_body_with_trailers(&[("server-timing", b"disk;dur=3")], &mut out)?;
assert!(out.ends_with(b"0\r\nserver-timing: disk;dur=3\r\n\r\n"));
# Ok::<(), http_net::HttpError>(())
```
### New - interim responses both ways
`ServerConn::send_informational` writes a 1xx other than 100 and 101 (for
example `103 Early Hints` with `link` fields) any number of times before
the final response, for any method, never to HTTP/1.0 (RFC 9110 section
15.2): the new `InterimNotAllowed` refuses that and any interim response
after the final one started. `ClientConn::set_report_informational(true)`
reports each interim response as `ClientEvent::Informational`; by default
they are skipped. The client now also counts interim responses against
`Limits::max_interim` (16) as well as the header byte budget, so a server
that never sends a final response is cut off at `TooManyInterim`.
### New - the client side of `Expect: 100-continue`
`Request::expect_continue()` adds `expect: 100-continue` and holds a
streamed body back: `is_waiting_for_continue()` is true until the server
answers `100` (reported as `ClientEvent::Continue`), a final status, or the
caller sends the body anyway (RFC 9110 section 10.1.1 allows it). A final
status means the server does not want the body: `send_body` then fails, and
the connection closes after the response. A full body with
`expect_continue` is refused, since it would be written at once.
### New - driver controls and `Date`
- `ServerConn::set_keep_alive(false)` makes the current (or next) response
carry `connection: close` and ends the connection after it: the gentlest
load-shedding step. `keep_alive()` says whether the connection can stay
open; `requests_served()` counts completed responses.
- `progress()` on both connections returns a `Progress` phase (`Idle`,
`Head`, `Body`, `AwaitingResponse`, `WritingBody`, `Upgraded`, `Closed`),
so a driver can start a header deadline at the first byte of a request
and never count idle keep-alive time against it.
- `HttpDate` (IMF-fixdate) and `DateCache`, which formats at most once per
second; the core takes the time from the caller, and
`ServerConn::respond_with_date` writes it. `ClientConn::is_reusable`.
### New - views and registry
`header_list` on both views returns a `ListElements` iterator over a list
field's elements across every line; `RequestHead::upgrade_protocols` lists
the offered protocols without `h2c`; `RequestHead::has_unknown_expectation`
reports an expectation other than `100-continue` (answer 417 if you like;
the blocking server does); `ResponseHead::is_informational`;
`status_allows_body(status, method)`.
### Changed - budgets and reserved fields
- **One field line is at most 8 KiB by default** (`Limits::max_field_line`,
new error `FieldLineTooLong`, 431), checked before the rest of the line
is buffered. In 0.3 a single line could fill the 16 KiB header section.
- Trailer sections have their own budgets (`max_trailer_bytes`,
`max_trailer_fields`), following the header budgets until set; the
buffer capacity grows only if the trailer budget is larger.
- `trailer`, `keep-alive`, and `proxy-connection` are reserved in outgoing
messages, and `te` in responses (`ReservedHeader`).
- `send_continue` reports `InterimNotAllowed` (was `InvalidState`) for the
HTTP/1.0 and ordering cases.
### Fixed - lists split around quoted strings
Every list the crate splits (`Connection`, `Transfer-Encoding`, `Expect`,
`Upgrade`, and the new `header_list`) is walked with quoted strings in mind:
a comma inside quotes no longer ends an element. This is the behaviour the
shared grammar crate `parse-net` specifies. No message 0.3 accepted reads
differently, so it removes a latent differential before the two crates
meet.
### New - blocking adapter
- `ServerConfig::with_response_deadline` (60 s by default) bounds the total
time to write one response, so a client that reads one byte at a time
cannot hold a worker thread (threat T-26); `Connection` has the same
deadline.
- `ServerConfig::with_max_requests_per_connection` closes a connection
after its last allowed response.
- `Server` answers unknown expectations with `417 Expectation Failed` and
takes `date` from `blocking::now_date`, a per-thread cache.
- `Connection::send_informational`; `Client::request` for any `Request` to
an `http://` URL; `Client::with_deadline` for one total deadline per
exchange; `blocking::post`; `blocking::serve` (bind and serve in one
call).
### Plan
`parse-net` is not published yet, so the shared grammar stays inside the
crate for now, behind the API shape `parse-net` specifies; the switch comes
with the first `parse-net` release (decision D-13). `dev/PLAN.md` records
the 0.4 decisions D-13 to D-20.
## Testing
254 tests plus 276 doctests, all passing (`cargo test --all-features`):
- Unit tests (81) in each module, now also for the date formatter (the
calendar is checked against a day-by-day walk from 1970 to 2400), the
quote-aware list walker, trailer validation, interim responses,
keep-alive control, and the budgets.
- New suites: `tests/trailers.rs` (19: reported only when asked, never for
an empty section, budgets, every forbidden name refused with nothing
written, dropped but validated for HTTP/1.0 and `HEAD`, round trips at
every split, redaction), `tests/interim.rs` (13: `103` ordering, every
refusal, reporting on and off, `max_interim`, the `Expect` flows both
ways), `tests/connection_control.rs` (15: `set_keep_alive`,
`requests_served`, `progress` at every phase, `respond_with_date`,
unknown expectations, `upgrade_protocols`, reserved names, the 8 KiB
field-line budget at and past the boundary, byte by byte).
- Property tests (`tests/prop_roundtrip.rs`, 17, four new): trailers round
trip in both directions, interim responses round trip (reported and
skipped), and `DateCache` equals direct formatting for any second up to
the end of the year 9999.
- Allocation counting (`tests/alloc.rs`, 12, two new): 10,000 server
requests with reported trailers and 10,000 client responses with two
`103`s and trailers each allocate nothing after warm-up.
- Real sockets (`tests/localhost.rs`, 28, nine new): the response deadline
against a client that reads one byte at a time, the request cap, `417`,
`send_informational`, `Client::request`, `post`, the client deadline
against a silent server, and an IMF-fixdate `date` on every response.
- The conformance table grows to 62 RFC statements (RFC 9110 sections
15.2, 10.1.1, 6.5.1, 6.5 added).
- Fuzzing: the harness now toggles trailer and interim reporting, the new
budgets, and `Expect` from fuzzer input, with new oracles (events only
when asked for, never an empty trailer event, `requests_served` equal to
the answers given, `h2c` never offered). Each of the six targets ran 60 s
with no finding (0.4 to 5.0 M executions).
## Performance
Criterion medians in WSL Ubuntu on the same Windows x86_64 machine,
release profile, library only, the process pinned to one core
(`taskset`), the 0.3.0 and 0.4.0 builds run alternately five times each so
machine load falls on both:
| 12-field browser GET, parsed and answered | ~315 ns | ~316 to ~331 ns | +0.3% to +5.2% (see below) |
| 100,000 pipelined GETs | ~10.0 ms | ~10.1 ms | +0.5% |
| 1 MiB body, `Content-Length` | ~14.7 µs | ~14.5 µs | -1.7% |
| 1 MiB body, chunked (4 KiB chunks) | ~19.1 µs | ~19.8 µs | +3.7% |
| response serialization (five fields, 1 KiB body) | ~89.5 ns | ~91.6 ns | +2.3% |
| client parsing a 1 KiB response | ~110 ns | ~110 ns | -0.4% |
The head benchmark is the one that moved: across five independent
interleaved sets (three on Windows, two pinned in WSL) the difference was
+4.6%, +0.3%, +3.6%, +5.2%, and +0.3%, so 0.4's new per-request facts (the
stored target form and `Content-Length`, the unknown-expectation flag, the
field-line budget, the offered-upgrade scan) cost about 2 to 3% there,
inside the 5% regression budget. The machine had other load throughout,
so run-to-run variation is near 5%; the numbers are given as measured. A
fast path added during this measurement splits list values that contain
no quote character with the word-at-a-time comma search.
## Migration from v0.3.0
See `docs/MIGRATION.md`. In short: raise `Limits::with_max_field_line` if
you accept field lines over 8 KiB; match `InterimNotAllowed` where you
matched `InvalidState` from `send_continue`; remove `trailer`,
`keep-alive`, and `proxy-connection` (and `te` on responses) from builders;
expect `417` from the blocking server for unknown expectations and a 60 s
response deadline. The new event variants appear only when you opt in, and
both event enums are `#[non_exhaustive]`.
## Stability commitment
Pre-1.0: the API may still change in the 0.x series, and every change is
listed in `CHANGELOG.md` and `docs/MIGRATION.md`. The API is frozen at
1.0.0 until 2.0. The MSRV is Rust 1.85; raising it after 1.0 is a major
change.
## Verification
Run on Windows x86_64 and in WSL Ubuntu (Linux x86_64), each on Rust
stable and the 1.85 MSRV toolchain:
```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
cargo clippy --all-targets --no-default-features -- -D warnings
cargo test
cargo test --all-features
cargo test --no-default-features
cargo build --examples
cargo build --lib --no-default-features --target thumbv7em-none-eabihf
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --no-default-features
cargo package
cargo deny check
cargo audit
cargo +nightly fuzz run --target x86_64-unknown-linux-gnu <target> <corpus outside the repo> -- -max_total_time=60 # each of the six targets, WSL
```
CI runs the same checks on Linux, macOS, and Windows, stable and 1.85,
plus the `no_std` build and the 60 s fuzz smoke run per target.
## What's next
- **0.5.0, feature complete:** client pipelining, spans and copy-out for
zero-copy passports, stateless parsers, a standalone chunked decoder, an
allocation-free response writer over an `Output` trait, connection reuse
and buffer pooling, optional wiping of consumed input, hop-by-hop
forwarding helpers, HTTP/2 preface detection, and an opt-in obs-fold
tolerance for clients.
## Installation
```toml
[dependencies]
http-net = "0.4"
```
MSRV: Rust 1.85.
**Full Changelog:** https://github.com/jamesgober/http-net/compare/v0.3.0...v0.4.0