HTTP client as a first-class citizen: zero deps, bit-for-bit parity, async by default

rust dev.to

Webhooks, scraping, health checks, proxying. Every language solves outbound HTTP with an external library. In Fitz, http.get/post/put/delete/head/request are language builtins. Async from day one. Bit-for-bit parity between the interpreter and the native binary. No pip install requests / npm install axios / cargo add reqwest required.

The forgotten detail

Fitz has had @get/@post since Phase 4. Serving HTTP is trivial. But until last week, if your handler needed to call another service (Slack webhook, scraping, health check, proxying to an upstream), you had two options:

  1. Import Python with from python import urllib.request and bundle CPython (~22 MB extra to the binary).
  2. Don't do it.

Any serious project eventually needs this. I caught the gap building fitzwatch (open-source status page written in pure Fitz): the heart of the product is http.head(monitor.target) every N seconds. Without that line, the language didn't reach.

A week later, the 6 builtins are in main, with bit-for-bit parity between fitz run and fitz build, static validation via the checker, and zero external deps in the final binary.

The typical Python stack

pip install requests
# or pip install httpx (if you want async)
Enter fullscreen mode Exit fullscreen mode
import requests

r = requests.get("https://api.example.com/users/42", timeout=5)
if r.status_code == 200:
    user = r.json()
    print(user)
elif r.status_code == 404:
    print("not found")
else:
    raise Exception(f"unexpected status: {r.status_code}")

# Async version with httpx
import httpx
async with httpx.AsyncClient() as client:
    r = await client.get("https://api.example.com/users/42", timeout=5)
    user = r.json()
Enter fullscreen mode Exit fullscreen mode

Two libraries to cover sync and async. requests (sync, no asyncio integration) or httpx (async, different API).

The typical JS/TS stack

npm install axios
# or just fetch from std if Node 18+
Enter fullscreen mode Exit fullscreen mode
import axios from "axios"

const r = await axios.get("https://api.example.com/users/42", { timeout: 5000 })
if (r.status === 200) {
    console.log(r.data)
} else if (r.status === 404) {
    console.log("not found")
} else {
    throw new Error(`unexpected status: ${r.status}`)
}
Enter fullscreen mode Exit fullscreen mode

axios throws on any status >= 400 by default — either disable it or wrap in try/catch.

The typical Rust stack

# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
Enter fullscreen mode Exit fullscreen mode
let client = reqwest::Client::new();
let r = client
    .get("https://api.example.com/users/42")
    .timeout(Duration::from_secs(5))
    .send()
    .await?;

match r.status().as_u16() {
    200 => {
        let user: serde_json::Value = r.json().await?;
        println!("{}", user);
    }
    404 => println!("not found"),
    other => eprintln!("unexpected status: {}", other),
}
Enter fullscreen mode Exit fullscreen mode

Three deps, two crates to import, a Client to construct. Works very well — reqwest is excellent — but it's a separate world from std.

Same thing in Fitz

async fn run() -> Result<Null> {
    let r = http.get("https://api.example.com/users/42").await?
    match r.status {
        200 => print("user: {r.body}"),
        404 => print("not found"),
        s => print("unexpected status: {s}"),
    }
    return Ok(null)
}

// Top-level
match run().await {
    Ok(_) => print(""),
    Err(e) => print("transport error: {e}"),
}
Enter fullscreen mode Exit fullscreen mode

No pip install. No npm install. No cargo add. The http module is part of the fitz binary.

The raw table

Piece Python (requests/httpx) JS (axios) Rust (reqwest) Fitz
How you get it pip install npm install cargo add built-in
Sync + Async 2 different libs 1 lib 1 lib 1 async builtin
4xx/5xx as error depends (axios yes, requests no) yes by default no,Result<Response> only for transport no,r.status
Transport errors exception exception Result::Err Result::Err(Str)
JSON body auto manual .json() automatic in r.data manual .json::<T>().await? pass a Map<Str, Any> and it gets serialized
Content-Type: application/json auto on send manual with json= yes in axios manual with .json(&v) yes when body is Map
TLS depends on OS / OpenSSL native feature flag static rustls-tls, no OpenSSL on host
Integrated with OpenAPI schema not applicable not applicable not applicable uses the same types as server-side

The 6 builtins, all on one page

// GET without body
let r = http.get("https://example.com/api").await?

// HEAD — useful for health checks (no body download)
let r = http.head("https://example.com/healthz").await?

// DELETE without body
let r = http.delete("https://example.com/api/items/42").await?

// POST with Map body (auto-JSON + Content-Type)
let r = http.post(
    "https://example.com/api/items",
    {"name": "ada", "role": "admin"}
).await?

// PUT with Str body (no header changes)
let r = http.put("https://example.com/api/raw", "hello world").await?

// POST with Bytes body
let bin: Bytes = bytes([0x89, 0x50, 0x4e, 0x47])  // PNG signature
let r = http.post("https://example.com/upload", bin).await?

// request — low-level with options
let r = http.request({
    "method": "PATCH",
    "url": "https://example.com/api/items/42",
    "timeout_ms": 5000,
    "headers": {"X-Token": "abc"},
    "body": {"status": "active"},
    "follow_redirects": false,
}).await?

// Response: 4 typed fields
print("status      = {r.status}")
print("body        = {r.body}")
print("duration_ms = {r.duration_ms}")
let server: Str = match r.headers.get("server") {
    Ok(v) => v,
    Err(_) => "(unknown)",
}
print("server      = {server}")
Enter fullscreen mode Exit fullscreen mode

r is always an HttpClientResponse { status: Int, body: Str, headers: Map<Str, Str>, duration_ms: Int }. The type is a language built-in, parallel to Request/Response from server-side HTTP.

Error handling: the important delta

We made a different choice than the neighbor stacks: 4xx/5xx statuses are NOT errors. If the server responds 404 or 500, you get it as Ok(response) with r.status set. Only transport errors (DNS, timeout, TLS handshake, broken connection) drop into Result::Err(Str).

Why? Because 4xx/5xx have useful bodies. If your API responds 422 with {"error": "validation failed", "field": "email"}, you want to read that. Making it a throw / Err loses the body or forces you into a try/catch that catches too much.

let r = http.post("https://api.example.com/users", {"email": "bad"}).await?

if (r.status >= 400 and r.status < 500) {
    // Your API is telling you something. Read it.
    print("client error: {r.body}")
} else if (r.status >= 500) {
    print("server crashed: {r.status}")
} else {
    print("OK: {r.body}")
}
Enter fullscreen mode Exit fullscreen mode

Errs only show up when there was no response:

match http.get("https://does-not-exist.example").await {
    Ok(r)  => print("status: {r.status}"),
    Err(e) => print("transport: {e}"),
    // → "transport: http: error sending request..."
}

match http.request({"method": "GET", "url": "https://slow.example", "timeout_ms": 100}).await {
    Ok(r)  => print("reached"),
    Err(e) => print("timeout: {e}"),
    // → "timeout: http: error sending request..."
}
Enter fullscreen mode Exit fullscreen mode

The checker statically validates that you match both Ok and Err (rule 5.3.3 — exhaustiveness on Result). If you forget a branch, the binary doesn't compile.

Canonical webhook dispatcher (the real case)

We combine the whole Fitz stack in <100 LoC. An HTTP handler receives an event, dispatches to an upstream webhook without waiting for it, responds 202 to the client immediately:

@server(8080)
fn main() => 0

let WEBHOOK_URL: Str = config("WEBHOOK_URL")

type EventInput {
    event: Str,
    user: Str,
}

@background
async fn dispatch_webhook(event: Str, user: Str) -> Null {
    let payload: Map<Str, Str> = {
        "event": event,
        "user": user,
        "source": "fitz-app",
    }
    log.info("dispatching", event: event, user: user)

    let r = http.post(WEBHOOK_URL, payload).await
    match r {
        Ok(resp) => {
            if (resp.status >= 200 and resp.status < 300) {
                log.info(
                    "delivered",
                    event: event,
                    status: resp.status,
                    duration_ms: resp.duration_ms,
                )
            } else {
                log.warn("rejected", event: event, status: resp.status, body: resp.body)
            }
        }
        Err(e) => log.error("failed", event: event, error: e),
    }
    return null
}

@post("/events")
fn create_event(input: EventInput) {
    // Fire-and-forget — handler doesn't wait for the webhook.
    let _ = spawn(dispatch_webhook(input.event, input.user))

    return 202 {
        "status": "accepted",
        "event": input.event,
        "user": input.user,
    }
}
Enter fullscreen mode Exit fullscreen mode

No Celery, no RabbitMQ, no separate worker, no pip install anything. The spawn starts a fire-and-forget tokio task. The handler responds 202 in <10 ms, the upstream webhook keeps running in the background.

Quick comparison vs the same in FastAPI:

# FastAPI requires BackgroundTasks + httpx + a global client managed by hand.
from fastapi import BackgroundTasks
import httpx
import os

client = httpx.AsyncClient(timeout=30)
WEBHOOK_URL = os.environ["WEBHOOK_URL"]

async def dispatch_webhook(event: str, user: str):
    payload = {"event": event, "user": user, "source": "myapp"}
    logger.info("dispatching", extra={"event": event, "user": user})
    try:
        r = await client.post(WEBHOOK_URL, json=payload)
        if 200 <= r.status_code < 300:
            logger.info("delivered", extra={"event": event, "status": r.status_code})
        else:
            logger.warning("rejected", extra={"event": event, "status": r.status_code, "body": r.text})
    except httpx.RequestError as e:
        logger.error("failed", extra={"event": event, "error": str(e)})

@app.post("/events", status_code=202)
async def create_event(input: EventInput, bg: BackgroundTasks):
    bg.add_task(dispatch_webhook, input.event, input.user)
    return {"status": "accepted", "event": input.event, "user": input.user}
Enter fullscreen mode Exit fullscreen mode

Works, but adds httpx + global client management + BackgroundTasks + structured logger setup.

fitzwatch-style health checker

The case that unblocked the mini-track. Cron job every 30 seconds pinging endpoints with http.head:

type HealthResult {
    url: Str,
    up: Bool,
    status: Int = 0,
    duration_ms: Int = 0,
    error: Str?,
}

async fn check_one(url: Str) -> HealthResult {
    let resp = http.request({
        "method": "HEAD",
        "url": url,
        "timeout_ms": 5000,
        "follow_redirects": true,
    }).await
    match resp {
        Ok(r) => {
            return HealthResult {
                url: url, up: r.status < 400,
                status: r.status, duration_ms: r.duration_ms,
            }
        }
        Err(e) => {
            return HealthResult { url: url, up: false, error: e }
        }
    }
}

@cron("*/30 * * * * *")
async fn check_all() -> Null {
    let r1 = check_one("https://example.com").await
    if (r1.up) {
        log.info("health.up", url: r1.url, status: r1.status, duration_ms: r1.duration_ms)
    } else {
        log.warn("health.down", url: r1.url, error: r1.error)
    }
    // ...repeat for more targets
    return null
}
Enter fullscreen mode Exit fullscreen mode

Without @server, the binary stays alive blocking with automatic ctrl_c().await — perfect for systemd / Docker unit.

Why it matters

The 5 differentiators that justify the feature:

  1. Language built-in — part of the fitz binary. No pip install requests / npm install axios / cargo add reqwest. When someone starts a Fitz project, they already have it.
  2. Bit-for-bit parity fitz runfitz build — the standalone binary has reqwest statically linked with rustls-tls. Same behavior running in interpreter or production binary.
  3. First-class async citizen — the 6 builtins return Future<Result<HttpClientResponse>>. They integrate naturally with @cron/@background/HTTP handlers/spawn(...) without extra glue.
  4. Automatic Result<T> — transport errors as values. ? propagates, the checker requires static handling (rule 5.3.3). No silent try/catch.
  5. No external deps on the hostrustls-tls doesn't require libssl/openssl. The binary you produce in CI runs on any target machine without system libraries.

None of the neighbor stacks in the table provides outbound HTTP client as a language built-in with this combination.

What's next

Next focus: resume fitzwatch. The technical blocker is closed, the rest is product:

  • src/checks.fitz with the HTTP check runner (which now actually writes the natural http.head).
  • src/scheduler.fitz with @cron that scans due monitors and fires spawn(run_check(m.id)).
  • /public/status page with server-side rendered HTML.
  • @ws("/dashboard") for authenticated real-time updates.

Full-stack open-source status page in pure Fitz, single-binary deploy. No separate Postgres in the 90% case (SQLite is also native + cron-driven syncing).

Try it

# Linux/Mac
curl -fsSL https://raw.githubusercontent.com/Thegreekman76/fitz/main/install.sh | sh
# Windows with scoop
scoop bucket add fitz https://github.com/Thegreekman76/scoop-fitz
scoop install fitz

# Verify
fitz --version  # → fitz 0.17.0
Enter fullscreen mode Exit fullscreen mode

Runnable examples in the repo: examples/guide/17e-http-client-basico.fitz (the 5 common methods) · 17f-http-client-errores.fitz (timeout/DNS/4xx/5xx handling) · 17g-http-client-webhook.fitz (canonical dispatcher) · 17h-http-client-health-checker.fitz (cron+health checks).

Full feature doc in chapter 17 of the guide (subsection "HTTP client outbound" — Spanish).

Repo: github.com/Thegreekman76/fitz. Issues, ideas, and PRs welcome.

Source: dev.to

arrow_back Back to Tutorials