Webhooks, scraping, health checks, proxying. Cada lenguaje resuelve HTTP client outbound con una librería externa. En Fitz,
http.get/post/put/delete/head/requestson builtins del lenguaje. Async desde día uno. Paridad bit-a-bit entre el intérprete y el binario nativo. Sinpip install requests/npm install axios/cargo add reqwest.
El detalle que se olvida
Fitz tiene @get/@post desde Fase 4. Es trivial servir HTTP. Pero hasta hace una semana, si tu handler necesitaba llamar a otro servicio (webhook a Slack, scraping, health check, proxying a un upstream), tenías dos opciones:
- Importar Python con
from python import urllib.requesty bundlear CPython (~22 MB extra al binario). - No hacerlo.
Cualquier proyecto serio termina necesitando esto eventualmente. Detectamos el gap construyendo fitzwatch (status page open-source escrito en Fitz puro): el corazón del producto es http.head(monitor.target) cada N segundos. Sin esa línea, el lenguaje no llegaba.
Una semana después, los 6 builtins están en main, con paridad bit-a-bit entre fitz run y fitz build, validación estática vía checker, y zero deps externas en el binario final.
El stack típico de Python
pip install requests
# o pip install httpx (si querés 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 con httpx
import httpx
async with httpx.AsyncClient() as client:
r = await client.get("https://api.example.com/users/42", timeout=5)
user = r.json()
Dos librerías para soportar sync y async. requests (sync, no integra con asyncio) o httpx (async, otra API).
El stack típico de JS/TS
npm install axios
# o sólo fetch del std si 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 rechaza con throw cualquier status >= 400 por default — querés desactivarlo o manejar el try/catch.
El stack típico de Rust
# 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),
}
Tres deps, dos crates a importar, un Client que construir. Funciona muy bien — reqwest es excelente — pero es un mundo aparte del std.
Lo mismo en 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}"),
}
Cero pip install. Cero npm install. Cero cargo add. El módulo http es parte del binario fitz.
La tabla cruda
| Pieza | Python (requests/httpx) |
JS (axios) |
Rust (reqwest) |
Fitz |
|---|---|---|---|---|
| Cómo se trae | pip install |
npm install |
cargo add |
built-in |
| Sync + Async | 2 libs distintas | 1 lib | 1 lib | 1 builtin async |
| Error en 4xx/5xx | depende (axios sí, requests no) | sí por default | no,Result<Response> solo de transporte |
no,r.status
|
| Errores de transporte | excepción | excepción | Result::Err |
Result::Err(Str) |
| Body JSON automático | manual .json()
|
automático en r.data
|
manual .json::<T>().await?
|
mandás Map<Str, Any> y se serializa |
Content-Type: application/json automático al mandar |
manual con json=
|
sí en axios | manual con .json(&v)
|
sí cuando body es Map
|
| TLS | depende del OS / OpenSSL | nativo | feature flag |
rustls-tls estático, sin OpenSSL en el host |
| Schema OpenAPI integrado | no aplica | no aplica | no aplica | usás los mismos type del server-side |
Los 6 builtins, todos en una página
// GET sin body
let r = http.get("https://example.com/api").await?
// HEAD — útil para health checks (sin descargar body)
let r = http.head("https://example.com/healthz").await?
// DELETE sin body
let r = http.delete("https://example.com/api/items/42").await?
// POST con body Map (auto-JSON + Content-Type)
let r = http.post(
"https://example.com/api/items",
{"name": "ada", "role": "admin"}
).await?
// PUT con body Str (sin tocar headers)
let r = http.put("https://example.com/api/raw", "hello world").await?
// POST con body Bytes
let bin: Bytes = bytes([0x89, 0x50, 0x4e, 0x47]) // PNG signature
let r = http.post("https://example.com/upload", bin).await?
// request — low-level con opciones
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?
// Respuesta: 4 fields tipados
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 siempre es un HttpClientResponse { status: Int, body: Str, headers: Map<Str, Str>, duration_ms: Int }. El tipo es built-in del lenguaje, paralelo a Request/Response del HTTP server-side.
Manejo de errores: el delta importante
Decidimos algo distinto al stack vecino: status 4xx/5xx NO son errores. Si el server responde 404 o 500, lo recibís como Ok(response) con r.status set. Sólo los errores de transporte (DNS, timeout, TLS handshake, conexión rota) bajan a Result::Err(Str).
¿Por qué? Porque 4xx/5xx tienen body útil. Si tu API responde 422 con {"error": "validation failed", "field": "email"}, querés leer eso. Hacerlo un throw / Err pierde el body o te obliga a un try/catch que captura demasiado.
let r = http.post("https://api.example.com/users", {"email": "bad"}).await?
if (r.status >= 400 and r.status < 500) {
// Tu API te está diciendo algo. Leelo.
print("client error: {r.body}")
} else if (r.status >= 500) {
print("server crashed: {r.status}")
} else {
print("OK: {r.body}")
}
Los Err solo aparecen cuando no hubo respuesta:
match http.get("https://no-existe.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("alcanzamos"),
Err(e) => print("timeout: {e}"),
// → "timeout: http: error sending request..."
}
El checker valida estáticamente que matchees Ok y Err (regla 5.3.3 — exhaustividad sobre Result). Si te olvidás un brazo, el binario no compila.
Webhook dispatcher canónico (el caso real)
Combinamos todo el stack Fitz en <100 LoC. Handler HTTP recibe un evento, dispatcha a un webhook upstream sin esperarlo, responde 202 al cliente inmediato:
@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 no espera al webhook.
let _ = spawn(dispatch_webhook(input.event, input.user))
return 202 {
"status": "accepted",
"event": input.event,
"user": input.user,
}
}
Sin Celery, sin RabbitMQ, sin worker separado, sin pip install nada. El spawn arranca un task tokio fire-and-forget. El handler responde 202 en <10 ms, el webhook upstream sigue corriendo en background.
Comparación rápida vs lo mismo en FastAPI:
# FastAPI requiere BackgroundTasks + httpx + un client global manejado a mano.
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}
Funciona, pero suma httpx + manejo del client global + BackgroundTasks + setup de logger estructurado.
Health checker estilo fitzwatch
El caso que destrabó la mini-tanda. Cron job cada 30 segundos que pingea endpoints con 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)
}
// ...repetir para más targets
return null
}
Sin @server, el binario queda vivo bloqueante con ctrl_c().await automático — perfecto para systemd / unit Docker.
Por qué importa
Los 5 diferenciales que justifican el feature:
-
Built-in del lenguaje — parte del binario
fitz. Sinpip install requests/npm install axios/cargo add reqwest. Cuando alguien arranca un proyecto Fitz, ya lo tiene. -
Paridad bit-a-bit
fitz run↔fitz build— el binario standalone tienereqwestlinkeado estático conrustls-tls. Mismo comportamiento corriendo en intérprete o en binario producción. -
Async ciudadano de primera — los 6 builtins devuelven
Future<Result<HttpClientResponse>>. Se integran natural con@cron/@background/handlers HTTP/spawn(...)sin glue extra. -
Result<T>automático — errores de transporte como valores. El?propaga, el checker exige manejo estático (regla 5.3.3). Sin try/catch silencioso. -
Sin deps externas en el host —
rustls-tlsno exigelibssl/openssl. El binario que producís en CI corre en cualquier máquina del triple destino sin librerías del sistema.
Ninguno de los stacks vecinos del cuadro provee HTTP client outbound como builtin del lenguaje con esta combinación.
Lo que viene
Próximo norte: retomar fitzwatch. El blocker técnico está cerrado, el resto es producto:
-
src/checks.fitzcon runner del check HTTP (que ahora sí escribe elhttp.headnatural). -
src/scheduler.fitzcon@cronque escanea due monitors y disparaspawn(run_check(m.id)). -
/public/statuspage con HTML server-side rendered. -
@ws("/dashboard")para updates en tiempo real autenticados.
Status page open-source full-stack en Fitz puro, deploy de un solo binario. Sin Postgres separado en el caso 90% (SQLite también es nativo + cron-driven syncing).
Probarlo
# Linux/Mac
curl -fsSL https://raw.githubusercontent.com/Thegreekman76/fitz/main/install.sh | sh
# Windows con scoop
scoop bucket add fitz https://github.com/Thegreekman76/scoop-fitz
scoop install fitz
# Verificar
fitz --version # → fitz 0.17.0
Ejemplos runnable en el repo: examples/guide/17e-http-client-basico.fitz (los 5 métodos comunes) · 17f-http-client-errores.fitz (manejo de timeout/DNS/4xx/5xx) · 17g-http-client-webhook.fitz (dispatcher canónico) · 17h-http-client-health-checker.fitz (cron+health checks).
Doc completo del feature en el cap 17 de la guía (sub-sección "HTTP client outbound").
Repo: github.com/Thegreekman76/fitz. Issues, ideas y PRs bienvenidos.