Webhooks, scraping, health checks, proxying. Every language solves outbound HTTP with an external library. In Fitz,
http.get/post/put/delete/head/requestare language builtins. Async from day one. Bit-for-bit parity between the interpreter and the native binary. Nopip install requests/npm install axios/cargo add reqwestrequired.
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.requestand 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
fitzbinary. Nopip 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 hasreqweststatically linked withrustls-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-tlsdoesn't requirelibssl/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.fitzwith the HTTP check runner (which now actually writes the naturalhttp.head). -
src/scheduler.fitzwith@cronthat scans due monitors and firesspawn(run_check(m.id)). -
/public/statuspage 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.