Dev.to
HTTP client as a first-class citizen: zero deps, bit-for-bit parity, async by default
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:
Import Python with from python import urllib.request and bundle CPython (~22 MB extra to the binary).
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)
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()
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+
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}`)
}
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"
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),
}
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}"),
}
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}")
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}")
}
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..."
}
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,
}
}
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}
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
}
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:
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.
Bit-for-bit parity fitz run ↔ fitz build — the standalone binary has reqwest statically linked with rustls-tls. Same behavior running in interpreter or production binary.
First-class async citizen — the 6 builtins return Future<Result<HttpClientResponse>>. They integrate naturally with @cron/@background/HTTP handlers/spawn(...) without extra glue.
Automatic Result<T> — transport errors as values. ? propagates, the checker requires static handling (rule 5.3.3). No silent try/catch.
No external deps on the host — rustls-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
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.
1 hour ago