DEV Community

Cover image for HTTP client outbound como ciudadano de primera clase: cero deps, paridad bit-a-bit, async natural
Martin Palopoli
Martin Palopoli

Posted on

HTTP client outbound como ciudadano de primera clase: cero deps, paridad bit-a-bit, async natural

Webhooks, scraping, health checks, proxying. Cada lenguaje resuelve HTTP client outbound con una librería externa. En Fitz, http.get/post/put/delete/head/request son builtins del lenguaje. Async desde día uno. Paridad bit-a-bit entre el intérprete y el binario nativo. Sin pip 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:

  1. Importar Python con from python import urllib.request y bundlear CPython (~22 MB extra al binario).
  2. 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)
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 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()
Enter fullscreen mode Exit fullscreen mode

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+
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 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"
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

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}"),
}
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

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}")
}
Enter fullscreen mode Exit fullscreen mode

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..."
}
Enter fullscreen mode Exit fullscreen mode

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,
    }
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Built-in del lenguaje — parte del binario fitz. Sin pip install requests / npm install axios / cargo add reqwest. Cuando alguien arranca un proyecto Fitz, ya lo tiene.
  2. Paridad bit-a-bit fitz runfitz build — el binario standalone tiene reqwest linkeado estático con rustls-tls. Mismo comportamiento corriendo en intérprete o en binario producción.
  3. Async ciudadano de primera — los 6 builtins devuelven Future<Result<HttpClientResponse>>. Se integran natural con @cron/@background/handlers HTTP/spawn(...) sin glue extra.
  4. 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.
  5. Sin deps externas en el hostrustls-tls no exige libssl/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.fitz con runner del check HTTP (que ahora sí escribe el http.head natural).
  • src/scheduler.fitz con @cron que escanea due monitors y dispara spawn(run_check(m.id)).
  • /public/status page 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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)