The Http facade is the outbound side of HTTP - the Rust equivalent of
Laravel's Http:: helper. You reach for it when your handler, job, or
scheduled task needs to call somebody else's API: a payment gateway, a
geocoder, a webhook target, a Slack message. Fluent builder, JSON in
and out, retries with jitter, deterministic test fakes that record what
you sent. The same surface you used in Laravel, with task-local
isolation so parallel tests don't see each other's fakes.
use Http;
use json;
let resp = post
.bearer_token
.json
.send
.await?;
let body: Value = resp.json.await?;
That's the shape: Http::<verb>(url) returns a RequestBuilder; you
chain configuration onto it; .send().await returns a
ClientResponse. The backing client is one shared reqwest::Client
with rustls TLS, a 30s default timeout, and a suprnova/<version> user
agent - built lazily on first call.
The verbs
get
post
put
patch
delete
Every verb returns a RequestBuilder. The URL can be any
impl Into<String> - a &str, a String, or a Cow<str>. No
URL-building helpers ship in the facade; format the URL yourself or
reach for a query-string crate.
Bodies
Three ways to attach a body. Each one replaces any previously-set body.
JSON
use Serialize;
post
.json
.send
.await?;
.json(&value) accepts anything that implements serde::Serialize.
The wire Content-Type is set to application/json automatically.
If serialization fails (e.g. a map with a non-string key), the
builder records the error and send() surfaces it instead of
silently sending a null body.
Form
post
.form
.send
.await?;
.form(&value) serializes the value as application/x-www-form-urlencoded.
The value must serialize to a JSON object; the keys become form fields.
Same body-error semantics as .json - a serialization failure surfaces
through send().await?, never as a silent empty body.
Raw bytes
use Bytes;
let payload: Bytes = compress?;
post
.header
.body
.send
.await?;
.body(bytes) takes anything impl Into<Bytes>. You're responsible
for the Content-Type header - .body doesn't set one.
Headers and auth
get
.header
.header
.bearer_token
.send
.await?;
.header(name, value) appends; the framework doesn't dedupe, so two
calls with the same name send two headers and reqwest joins them per
HTTP semantics. Two shortcuts for the common auth schemes:
.bearer_token(token)- setsAuthorization: Bearer <token>.basic_auth(user, password)- setsAuthorization: Basic <b64>;passwordisOption<&str>so.basic_auth("api-key", None)encodes theapi-key:form some providers want
Timeouts
The shared client has a 30-second default timeout. Override per-request when you need to:
use Duration;
get
.timeout
.send
.await?;
.timeout(dur) overrides both the connect and the total request
timeout for this one call. There's no separate connect_timeout
knob on the builder; the underlying reqwest client uses one combined
timeout.
Redirects
The shared client follows redirects by default (up to reqwest's cap of
10) - the right behavior when you're calling a trusted endpoint that
answers http → https or hands you a CDN URL.
When the request URL is influenced by untrusted input, that default
becomes a server-side request forgery (SSRF) vector: a hostile endpoint
can answer with a 3xx whose Location points at an internal service or
a cloud-metadata address (http://169.254.169.254/…), and a following
client would chase it. Disable redirect-following for those requests with
.no_redirects():
let resp = get
.no_redirects
.send
.await?;
// The 3xx is returned as-is instead of being followed - inspect it and
// reject rather than letting the client chase the Location header.
if .contains
.no_redirects() routes the request through a separate non-following
client; the default client - and every request that doesn't call it - is
unchanged. This is the general-client analogue of the redirect lockdown
the web-push sender already applies to attacker-controlled push endpoints.
Retries
Http ships exponential-backoff retries with full jitter - the AWS
recipe, the same one Laravel uses. One rule decides every retry, for a
transport failure and for a received 5xx response alike. The two retry
modes differ in the methods they retry: .retry(...) retries GET, PUT
and DELETE, and .retry_non_idempotent(...) retries POST and PATCH
as well.
.retry(max_attempts, base_backoff) - retries for idempotent methods
use Duration;
let resp = get
.retry
.send
.await?;
max_attempts includes the first try, so retry(4, ...) retries up
to three times after the initial attempt. The delay before attempt
n+1 is a uniform random duration in [0, base_backoff * 2^(n-1)],
capped at 30 seconds. Full jitter, not exponential-backoff-plus-fixed-
sleep, so many workers retrying the same outage don't synchronize into
a thundering herd.
.retry() retries only idempotent methods (GET, PUT, DELETE): a
transport failure (connect, timeout, DNS) or a 5xx status triggers another
attempt. 4xx and 2xx/3xx responses are returned as-is. After exhausting
retries, the last response or transport error is returned to the caller.
POST and PATCH are sent once, whatever the failure. A transport failure
on a write can mean the server committed it but the response was lost, so
retrying could apply it twice. Opt in with .retry_non_idempotent(...)
when the upstream is protected by an idempotency key.
.retry_non_idempotent(...) - opt-in for POST/PATCH
post
.header
.retry_non_idempotent
.send
.await?;
When you've supplied an idempotency key the upstream honors, or you've
otherwise made the request safe to replay, switch to
.retry_non_idempotent(...). It retries GET, PUT and DELETE as
.retry() does, and it also retries POST and PATCH, after a transport
failure and after a 5xx response. It still returns 4xx and 2xx/3xx
responses as-is.
Retry-After is honored on 503
For a 503 Service Unavailable, the framework respects a Retry-After
header - in either delta-seconds (Retry-After: 30) or HTTP-date
(Retry-After: Tue, 15 Nov 1994 08:12:31 GMT) form. The actual wait
is the larger of the jittered backoff and the Retry-After hint,
still capped at 30 seconds. A hostile or misconfigured server returning
Retry-After: 86400 won't park your task for a day.
.retry_when(predicate) - narrow the policy further
use Duration;
let resp = get
.retry
.retry_when
.send
.await?;
retry_when registers a predicate consulted before every retry the
policy above would otherwise make. It can veto an otherwise eligible
retry, but it cannot manufacture one. In particular, it cannot turn a
2xx, 3xx, or 4xx response into a retry, and it cannot make a received
5xx response retryable for POST or PATCH without
.retry_non_idempotent(...). It is consulted for transport-error retries
and for 5xx retries by the same rule, and only for an attempt that the
policy would retry: not after the last attempt, and not for a POST or
PATCH under plain .retry(). Without a .retry(...) or
.retry_non_idempotent(...) policy, a lone retry_when has nothing to
veto.
The predicate receives RetryContext { attempt, method, url, outcome },
where outcome is RetryOutcome::TransportError (send failed before a
response arrived) or RetryOutcome::Status(n) (an eligible 5xx
response).
Reading the response
ClientResponse exposes status, headers, and three body-reading
methods. Each body method consumes the response.
let resp = get.send.await?;
let status: u16 = resp.status;
let etag: = resp.header;
// Pick one - each consumes the response.
let user: User = resp.json.await?;
// let text: String = resp.text().await?;
// let bytes: Bytes = resp.bytes().await?;
.header(name) is case-insensitive. .json::<T>() returns
Result<T, FrameworkError> and uses serde_json for decoding.
.text() enforces UTF-8 and surfaces a FrameworkError if the body
isn't valid UTF-8.
Response body cap
A slow or hostile upstream can otherwise stream an unbounded body into memory. To protect that, every buffered body read is capped - 25 MiB by default. Override globally at boot:
use Http;
// Once, somewhere in bootstrap.
set_max_response_bytes; // 100 MiB
Or per-request when one call legitimately handles a larger payload:
let bytes = get
.max_response_bytes // 500 MiB
.send
.await?
.bytes
.await?;
A response that declares a Content-Length over the cap is rejected
before any body is read; the streaming loop also enforces the cap
against the actual bytes, in case Content-Length is absent or lies.
Escape hatch - raw reqwest
The framework covers the common cases. When you need something we don't
expose - streaming bodies, multipart uploads, redirect policy
inspection, websocket upgrades - call .into_inner() to unwrap the
underlying reqwest::Response:
let resp = get.send.await?;
let raw: Response = resp.into_inner?;
let mut stream = raw.bytes_stream;
while let Some = stream.next.await
into_inner() returns Err(FrameworkError::internal(...)) when called
on a fake response - there's no underlying reqwest::Response in that
case. The response-body cap also no longer applies once you take the
raw response; you own the read from there.
For outgoing multipart uploads, drop down to reqwest::Client
directly via the same escape route.
Testing with Http::fake
This is the part you'll use every day. Http::fake runs your test body
inside a tokio::task_local! scope where every outbound call is
intercepted, captured, and answered with whatever you've queued.
use ;
async
Matching canned responses
fake_response(method, url_substring, status, body) queues a canned
response. The first outbound request whose method matches
(case-insensitive) and whose URL contains url_substring consumes the
canned entry and returns that response. Use method "*" to match any
method.
Subsequent matching requests fall through to the next canned entry of
the same shape, or - if none match - return an empty 200 {}. Queue
one canned response per expected call:
fake_response;
fake_response;
// Two GETs to /v1/customer get distinct responses; a third gets 200 {}.
Assertions
// Pass if at least one recorded request matches.
assert_sent;
// Pass if no recorded request matches.
assert_not_sent;
RecordedRequest exposes method: String, url: String,
headers: Vec<(String, String)>, and body: Option<Vec<u8>>. The
predicate runs against every recorded request; assertion failures
print the recorded list with header values and bodies redacted (a
small allowlist of Content-Type, Accept, and User-Agent is shown
in full; everything else is <redacted>). That keeps bearer tokens
and webhook payloads out of CI logs even when an assertion blows up.
Tests run in parallel safely
The fake state lives in a tokio::task_local! - every fake scope is
scoped to the task running the test, not the process. Two tests
running concurrently on different tasks each get their own
recorded-requests vec and their own canned-response queue. No shared
mutex, no test ordering, no #[serial].
async
async
The spawned-task gotcha
tokio::task_local! is scoped to the current task. Work that goes
through tokio::spawn lands on a fresh task and does NOT inherit
the fake - by default, outbound calls from the spawned future hit the
real network. Two helpers address this.
Http::fail_on_real_calls() and FailOnRealCallsGuard
Flips a process-global flag that turns any unmatched outbound call
into a FrameworkError::internal(...) instead of letting it hit the
network. This is Suprnova's analogue of Laravel's
Http::preventStrayRequests() - it catches the exact bug the gotcha
creates.
Use the RAII guard so the flag resets when the test ends, even on panic:
use FailOnRealCallsGuard;
async
Nested guards compose correctly: the inner guard's Drop restores
the PREVIOUS state, not unconditionally "allowed". So an inner test
helper that installs its own guard inside an outer guarded scope
doesn't disarm the outer guard on the way out.
The flag is process-global by design. The point is catching a
tokio::spawn-ed future silently escaping a fake scope and pinging a
real third party from CI. A per-task flag would miss that.
Http::spawn_with_fake_inheritance(future)
When code under test legitimately spawns a task - a queue worker, a
background syncer, a sub-task - and you want its outbound calls to go
through the parent's fake, swap tokio::spawn for
Http::spawn_with_fake_inheritance:
fake
.await;
If no fake scope is active when you call
spawn_with_fake_inheritance, it's equivalent to tokio::spawn - the
child runs without any fake context. So you can use it
unconditionally in code that's sometimes tested with Http::fake and
sometimes not.
Belt-and-braces in test setup
The two combine. A test that wants to be loudly safe pairs them:
async
Without the guard, an URL or method that drifts from the fake silently
falls through to a default 200 {}, and your test passes despite the
production code calling a different endpoint. With the guard, you
fail loudly on the first mismatch.
OpenTelemetry trace propagation
When the framework is built with the otel feature and a W3C
TraceContext propagator is installed, every outbound Http::* request
injects traceparent (and tracestate when non-empty) into its
headers - so downstream services can continue the trace. No
configuration on the call site; the propagator reads
opentelemetry::Context::current() at send time.
Without an active OTel context, no headers are injected and outbound requests look exactly like they did before. See Observability for the propagator setup.
Why Suprnova diverges
Three small divergences from Laravel's Http:: facade are worth calling
out.
Task-local fakes instead of a process-global mock store. Laravel's
Http::fake() mutates a process-wide registry; tests serialize on it,
or you accept that parallel runners can race. Suprnova's Http::fake
uses tokio::task_local! so two tests on two tasks each see their own
fake - no test ordering, no shared mutex. The price is that
tokio::spawn-ed work doesn't inherit the fake by default, which is
why Http::spawn_with_fake_inheritance and
FailOnRealCallsGuard exist. Together they give you the same
"can't accidentally hit production" guarantee that
Http::preventStrayRequests() does in Laravel, with stricter scoping.
Retries default to refusing POST/PATCH. Laravel's HTTP client
retries any method by default. Suprnova's .retry(...) sends POST and
PATCH once, after a transport failure and after a 5xx response alike.
Use .retry_non_idempotent(...) to opt into retries for those methods
only after making the write safe to replay, typically with an idempotency
key the upstream honors.
retry_when can only narrow, never widen. Laravel's retry()
$when callback fully replaces the "should retry" decision, so it can
retry statuses the framework wouldn't otherwise touch (a 404, say).
Suprnova's retry_when only vetoes a retry .retry(...) or
.retry_non_idempotent(...) already decided to make. It is consulted
for every retry the policy would make, but cannot turn a 2xx, 3xx, or
4xx response into a retry or make a POST or PATCH eligible under plain
.retry().
Edge cases and small print
Http::*is closed. We deliberately don't expose the underlyingreqwest::Client. To grow the surface, add a method to the facade rather than reaching forreqwestdirectly - except via the documentedinto_inner()escape hatch on a real response.- The shared client is built once and lives forever. Built lazily
on first call to any
Http::*verb, kept in aOnceLock. The rustls TLS stack and the 30s default timeout are baked in. - JSON/form serialization failures fail loudly. A
.json(&unserializable)builder records the error andsend()returns it asFrameworkError::internal(...). The request never goes out - we don't degrade to anullbody. - The 30s retry ceiling is hard. The backoff math caps at 30
seconds; the
Retry-Afterinterpretation caps at 30 seconds; no single retry sleep parks a task for longer. - Process-global cap is one-shot.
Http::set_max_response_bytesis a write to a process-global atomic - set it once at boot, then override per-request as needed. There's no "reset to default" call.
Next
- Mail - outbound email, which uses similar fake / driver patterns for tests
- Notifications - notification channels including web push, all share the same test-fake philosophy
- Queues - jobs that make outbound HTTP calls, plus the
spawn_with_fake_inheritancepattern for testing workers - Testing -
#[suprnova_test],TestContainer, and the rest of the fakes surface - Observability - OTel propagator setup that makes
traceparentinjection light up
