Manual contentsDigging DeeperBrowse 113 chapters
Manual 17 min read

HTTP Client

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 suprnova::Http;
use serde_json::json;

let resp = Http::post("https://api.stripe.com/v1/charges")
    .bearer_token(secret_key)
    .json(&json!({ "amount": 1000, "currency": "usd" }))
    .send()
    .await?;

let body: serde_json::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

Http::get("https://api.example.com/users/42")
Http::post("https://api.example.com/users")
Http::put("https://api.example.com/users/42")
Http::patch("https://api.example.com/users/42")
Http::delete("https://api.example.com/users/42")

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 serde::Serialize;

#[derive(Serialize)]
struct CreateUser {
    name: String,
    email: String,
}

Http::post("https://api.example.com/users")
    .json(&CreateUser {
        name: "Ada".into(),
        email: "ada@example.com".into(),
    })
    .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

Http::post("https://login.example.com/oauth/token")
    .form(&serde_json::json!({
        "grant_type": "client_credentials",
        "client_id": id,
        "client_secret": secret,
    }))
    .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::Bytes;

let payload: Bytes = compress(report)?;
Http::post("https://collector.example.com/ingest")
    .header("Content-Type", "application/octet-stream")
    .body(payload)
    .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

Http::get("https://api.example.com/private")
    .header("X-Request-Id", request_id)
    .header("Accept", "application/vnd.api+json")
    .bearer_token(api_key)
    .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) - sets Authorization: Bearer <token>
  • .basic_auth(user, password) - sets Authorization: Basic <b64>; password is Option<&str> so .basic_auth("api-key", None) encodes the api-key: form some providers want

Timeouts

The shared client has a 30-second default timeout. Override per-request when you need to:

use std::time::Duration;

Http::get("https://slow.example.com/report")
    .timeout(Duration::from_secs(120))
    .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 = Http::get(user_supplied_url)
    .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 (300..400).contains(&resp.status()) {
    return Err(AppError::bad_request("refusing to follow a redirect"));
}

.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 std::time::Duration;

let resp = Http::get("https://flaky.example.com/health")
    .retry(4, Duration::from_millis(200))
    .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

Http::post("https://api.example.com/charges")
    .header("Idempotency-Key", idem_key)
    .retry_non_idempotent(3, Duration::from_millis(200))
    .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 std::time::Duration;

let resp = Http::get("https://flaky.example.com/health")
    .retry(4, Duration::from_millis(200))
    .retry_when(|ctx| ctx.method == "GET")
    .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 = Http::get("https://api.example.com/users/42").send().await?;

let status: u16 = resp.status();
let etag: Option<String> = resp.header("ETag");

// 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 suprnova::Http;

// Once, somewhere in bootstrap.
Http::set_max_response_bytes(100 * 1024 * 1024); // 100 MiB

Or per-request when one call legitimately handles a larger payload:

let bytes = Http::get("https://example.com/big-export.json")
    .max_response_bytes(500 * 1024 * 1024) // 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 = Http::get("https://example.com/big-stream").send().await?;
let raw: reqwest::Response = resp.into_inner()?;
let mut stream = raw.bytes_stream();
while let Some(chunk) = stream.next().await {
    process(chunk?);
}

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 suprnova::{Http, fake_response, assert_sent};

#[tokio::test]
async fn creates_a_user_via_api() {
    Http::fake(|| async {
        fake_response(
            "POST",
            "/api/users",
            201,
            serde_json::json!({ "id": 42, "name": "Ada" }),
        );

        let resp = Http::post("https://example.com/api/users")
            .json(&serde_json::json!({ "name": "Ada" }))
            .send()
            .await
            .unwrap();

        assert_eq!(resp.status(), 201);
        let body: serde_json::Value = resp.json().await.unwrap();
        assert_eq!(body["id"], 42);

        assert_sent(|r| r.method == "POST" && r.url.contains("/api/users"));
    })
    .await;
}

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("GET", "/v1/customer", 200, json!({ "id": "cus_1" }));
fake_response("GET", "/v1/customer", 200, json!({ "id": "cus_2" }));
// Two GETs to /v1/customer get distinct responses; a third gets 200 {}.

Assertions

// Pass if at least one recorded request matches.
assert_sent(|r| r.method == "POST" && r.url.contains("/charges"));

// Pass if no recorded request matches.
assert_not_sent(|r| r.url.contains("/refunds"));

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].

#[tokio::test]
async fn first_test() {
    Http::fake(|| async {
        fake_response("GET", "/a", 200, json!({"who": "first"}));
        let _ = Http::get("https://x.test/a").send().await.unwrap();
        assert_sent(|r| r.url.contains("/a"));
        // Sibling test's request to /b is invisible here.
    })
    .await;
}

#[tokio::test]
async fn second_test() {
    Http::fake(|| async {
        fake_response("GET", "/b", 200, json!({"who": "second"}));
        let _ = Http::get("https://x.test/b").send().await.unwrap();
        assert_sent(|r| r.url.contains("/b"));
    })
    .await;
}

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 suprnova::FailOnRealCallsGuard;

#[tokio::test]
async fn no_test_makes_a_real_call() {
    let _guard = FailOnRealCallsGuard::install();

    // Any unfaked outbound HTTP call from anywhere inside this test
    // - including from a `tokio::spawn`-ed task - errors with a
    // message naming the URL. No network IO actually happens.
}

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:

Http::fake(|| async {
    fake_response("GET", "/child", 204, json!({}));

    let handle = Http::spawn_with_fake_inheritance(async {
        // Runs on a NEW task, but the parent's fake state is
        // re-installed in this task's task-local scope. The send
        // is intercepted; the response is the 204 above.
        Http::get("https://child.example.com/child").send().await
    });

    let response = handle.await.unwrap().unwrap();
    assert_eq!(response.status(), 204);

    // Recorded requests from the child show up here - the
    // Arc<Mutex<FakeState>> is shared, not snapshotted.
    assert_sent(|r| r.url.contains("/child"));
})
.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:

#[tokio::test]
async fn pays_the_invoice() {
    let _guard = FailOnRealCallsGuard::install();

    Http::fake(|| async {
        fake_response("POST", "/v1/charges", 200, json!({ "id": "ch_1" }));

        // If a typo on the URL or method drifts away from the fake,
        // the request falls through to the guard, which errors out
        // with a message naming the URL - instead of silently
        // returning an empty 200 that hides the mismatch.
        pay_invoice(&invoice).await.unwrap();

        assert_sent(|r| r.url.contains("/v1/charges"));
    })
    .await;
}

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 underlying reqwest::Client. To grow the surface, add a method to the facade rather than reaching for reqwest directly - except via the documented into_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 a OnceLock. 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 and send() returns it as FrameworkError::internal(...). The request never goes out - we don't degrade to a null body.
  • The 30s retry ceiling is hard. The backoff math caps at 30 seconds; the Retry-After interpretation caps at 30 seconds; no single retry sleep parks a task for longer.
  • Process-global cap is one-shot. Http::set_max_response_bytes is 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_inheritance pattern for testing workers
  • Testing - #[suprnova_test], TestContainer, and the rest of the fakes surface
  • Observability - OTel propagator setup that makes traceparent injection light up