crux_http-v0.20.0
0.20.0 - 2026-08-06
Added
-
HttpError::body,HttpError::body_jsonandHttpError::code— read what the
server said when it rejected a request, without destructuring the variant.A 4xx/5xx arrives as
Err(HttpError::Http { code, message, body }), andbodyhas
always held the server's own explanation. ButDisplayshows only the status
("HTTP error 409: 409 Conflict"), so getting at the message meant hand-rolling
this in every app:// before let crux_http::HttpError::Http { body: Some(body), .. } = error else { return None }; serde_json::from_slice::<serde_json::Value>(body) .ok() .and_then(|b| b.get("error").and_then(|e| e.as_str()).map(String::from)) // after error.body_json::<serde_json::Value>().ok() .and_then(|b| b["error"].as_str().map(String::from))
body_jsondeserializes into whatever shape your API uses — your own envelope, an
RFC 7807problem+jsonstruct, orserde_json::Value. Note that body decoding is
skipped for an error status, so the raw error body survives even for a request
built withexpect_json::<T>(); there is now a test pinning that. -
HttpError::header,HttpError::headersandHttpError::content_type— read the
headers of the response that was rejected.A rejection's policy often lives only in its headers, and none of it is recoverable
from the status or the body:Retry-Afteron a 429 or 503,WWW-Authenticateon a 401
(expired token vs insufficient scope), rate-limit headers, or theContent-Typethat
says whether an error body is JSON, an RFC 7807 document, or a proxy's HTML page.
Response::newused to drop theHeaderMapon the error branch, and sincecrux_http
middleware does not run in the command API, an app had no way to see any of it.if let Some(retry_after) = error.header("retry-after") { /* back off politely */ }
This adds a
headersfield toHttpError::Http— see the breaking change below. -
crux_http::testing::rejection(status, body)— builds the
crux_http::Result<Response<Body>>a feature receives when the server rejects a
request, via the same conversion a real shell response takes:let event = Event::Saved(crux_http::testing::rejection(409, r#"{"error":"…"}"#));
Use it wherever you would previously have fabricated
Ok(response_with_409)(see
the breaking change below). -
crux_http::testing::rejection_from(HttpResponse)— the header-carrying form,
taking the same protocol response you would resolve a request with in an end-to-end
test, so both styles of test describe a rejection the same way:let result = rejection_from(HttpResponse::status(429).header("retry-after", "30").build());
rejection(status, body)is unchanged, and is now sugar over it. -
crux_http::testingnow has module docs, stating the invariant the builders divide:
ResponseBuilderfor theOk(Response)of a successful exchange,rejection/
rejection_fromfor theErrof a rejection. There is no third case. -
HttpRequestBuilder::body_json, which sets a JSON body and
content-type: application/json, mirroring what
command::RequestBuilder::body_jsonputs on the wire.The protocol builders are how a test names the request it expects, but
HttpRequestBuilder::jsonsets only the body, where the capability side sets the
mime too (viaBody::from_json). Mirroring a real request therefore failed on
thecontent-typeheader alone unless you knew to add it by hand:// before — passes only with the header spelled out assert_eq!( &request.operation, &HttpRequest::post(URL) .header("content-type", "application/json") .json(&body) .build() ); // after assert_eq!(&request.operation, &HttpRequest::post(URL).body_json(&body).build());
jsonis unchanged and still sets only the body: these builders construct
protocol-layer values, so they must stay able to express a JSON body with no
content-type(or a malformed one), and changingjsonwould silently break
tests that already add the header themselves.
💥 Breaking Changes
-
HttpErrorhas two new variants, andHttpError::Httpnow means only a server
rejection. Two things that were not rejections used to report themselves as one:Was Is now Response::body_byteson an already-taken body →Http { code: <the success status>, headers: <that response's>, .. }HttpError::BodyAlreadyTakenA shell status outside 100–999 → Http { code: 999, .. }HttpError::InvalidStatusCode(999)So
error.code()could returnSome(200), andmatches!(err, HttpError::Http { .. })
was not a reliable test for "the server rejected this". Both now are:// `Some` if and only if the server rejected the request if let Some(code) = error.code() { … }
BodyAlreadyTakencarries nothing: the caller still holds theResponse, so the status
and headers the old error reported were already in hand — and they described a successful
response, which is what made them misleading.An exhaustive
matchonHttpErrorneeds two new arms. That is deliberate: the enum is
not#[non_exhaustive], so a new failure mode is a compile error you get to think
about, rather than something that silently lands in a wildcard. -
From<http_types::Error> for HttpErroris gone (http-typesfeature only). It mapped
a middleware error ontoHttpError::Http, which now means only a server rejection, and it
has no callers inside the crate.http-typesmiddleware must map its own errors
explicitly.It isn't rehomed onto a new variant because the
http-typesfeature is slated for
removal in full —httpis the only HTTP type systemcrux_httpwill keep. This is the
first piece to go;crux_http::compatand thepub use http_typesre-export follow in
their own release. If you still depend on the feature, now is the time to say so. -
HttpError::Httphas a newheadersfield, so that a rejection's headers reach the
app (seeHttpError::headerabove). It isBox<HeaderMap>, boxed only because a bare
HeaderMapis 96 bytes and this type is theErrof nearly every function in the crate —
inline, it pushedHttpErrorto 160 bytes and trippedclippy::result_large_erracross
the crate.Neither
headersnorbodyis optional, andbodyis now a plainVec<u8>rather than
Option<Vec<u8>>.Response::newis the variant's only constructor and always supplies
both, so theOptions described states that could not arise. Both types are
niche-optimised, so this costs nothing at runtime —HttpErrorstays 72 bytes — and the
accessors are unchanged:body()still returnsNonefor an empty body, andheaders()
still returnsNone, but now that means simply "not a rejection".matcharms that name only the fields they use are unaffected:Err(HttpError::Http { code, .. }) if code == 401 => { … } // still compiles
The variant is also now
#[non_exhaustive], so this is the last time a new field
breaks you: onlycrux_httpconstructs it, and what a rejection carries can grow behind
the accessors. Code that constructed it — in practice, tests — must switch to
crux_http::testing::rejection/rejection_from, which build it through the real
conversion:// before let error = HttpError::Http { code: 409, message: "409 Conflict".into(), body: Some(body) }; // after let error = crux_http::testing::rejection::<Vec<u8>>(409, body).unwrap_err();
Note that
HttpError'sPartialEqnow compares headers too, so two rejections that
differ only in what the server sent in its headers are no longer equal. Compare against
rejection/rejection_fromrather than a value assembled by hand. -
ResponseBuilder::with_statusnow panics for a 4xx or 5xx status. It could
previously build aResponsecarrying an error status — a value no app can ever
receive, becausecrux_httpconverts those responses into
Err(HttpError::Http { .. })before the event is sent.This mattered in practice. A downstream app had eight call sites shaped like this:
match result { Ok(mut response) => { if response.status().is_success() { /* … */ } else { show(api::error_reason(&mut response)) } // unreachable } Err(error) => fail(&error), // "HTTP error 409: 409 Conflict" }
The
elsebranch cannot run, so users saw the bare status instead of the sentence
the service had written for them. Seven of those sites had passing tests for the
message, because each test built the impossible value —
Ok(ResponseBuilder::with_status(409).body(…).build())— and asserted the dead
branch. Code review passed all eight.The panic turns exactly those tests red, with a message naming the replacement.
Migration is mechanical:// before — asserts a state the app can never observe let result = Ok(ResponseBuilder::with_status(409).body(body).build()); // after — what the app really receives let result = crux_http::testing::rejection(409, body);
Non-error statuses (1xx, 2xx, 3xx) are unaffected, including non-standard ones.
Nothing about the runtime behaviour of a request changes — this is a test-helper
guard rail plus documentation of an invariantcrux_httpalready had.
Documentation
-
Response,Response::status,HttpError::Httpand the crate root now state the
invariant plainly: anOk(Response)never carries a 4xx or 5xx status, so a
rejection can only be handled on theErrside. TheResponsedocs carry the
correct match shape as a compiled example. -
command::RequestBuilder::middlewarenow warns that it is a no-op: nothing on the
command API executes a middleware stack, so middleware pushed there —Redirect
included — is accepted and silently ignored. Its example previously implied redirects
were followed. This documents existing behaviour; the underlying gap is
#556.
⚙️ Miscellaneous Tasks
- Align with
crux_core0.20.0. Upgrade the other capability crates
(crux_kv0.14.0,crux_time0.18.0) together with this one — a capability left on
crux_core0.19 would pull a second, incompatiblecrux_coreinto the same tree. - Dependency updates, including
http1.4 -> 1.5. - Dropped the
anyhowandpin-project-litedependencies, which nothing in the crate
referenced, andcategoriesadded to the crate metadata for crates.io.