M5.C4 — Jobs sin Celery: @cron, @background, spawn y persistencia¶
Pre-requisitos: M5.C1 — async, M5.C3 —
WS. Vas a usar async fn para handlers de
cron con I/O, y vas a entender por qué el runtime tokio puede
correr cron + handlers HTTP + WS en simultáneo sin
configuración. Para la sección iter2 (persistencia), también es
útil tener el stack de DB documentado en DB y ORM
visto, aunque acá vas a aprender el mínimo necesario.
Objetivo: hacer tareas programadas + fire-and-forget sin
broker externo, sin Celery, sin Redis, sin systemd timers,
sin pip install ni cargo add. Tres piezas nativas: @cron("expr")
para periódicos, @background para autorizar callsites,
spawn(fn_call) para fire-and-forget desde un handler. Y la
iter2 (v0.11.2): tz, retry, catch_up, store=db para
endurecerlo para producción.
Por qué importa: el patrón "API + jobs" es universal. En
Python usás Celery + Redis + worker pool + beat. En Node, Bull +
Redis + cron node. En Java, Spring @Scheduled + Quartz +
ExecutorService. En Go, robfig/cron + goroutines + manual.
Todos comparten 3 problemas: un broker externo, un proceso
worker aparte, y configuración de retry/timezone/persistencia
hecha a mano y bug-prone.
Fitz lo trae en el lenguaje. Cron expressions con tz IANA, retry con backoff exponencial, persistencia opt-in sobre Postgres, catch_up policy, todo built-in. Ningún otro lenguaje hoy combina cron + background workers + spawn tipado en el core sin broker y con paridad intérprete↔binario.
Cross-link: Guía cap 30 — Jobs sin Celery.
Mapa del cap¶
flowchart LR
A["@cron expr"] --> B[scheduler tokio]
B --> C[fn corre cada tick]
D["@background fn"] --> E[autorizada para spawn]
E --> F[spawn fn_call desde handler]
F --> G[Future detached]
H["iter2 store=db"] --> I[persiste runs en fitz_cron_runs]
J[iter2 retry] --> K[backoff exponential lineal const]
L[iter2 tz IANA] --> M[interpretado en huso del usuario]
N["iter2 catch_up"] --> O[un run al boot si missed]
Por qué Fitz es distinto¶
| Feature | Python (Celery) | Node (Bull) | Java (Spring @Scheduled) |
Go (robfig/cron) |
Fitz |
|---|---|---|---|---|---|
| Setup mínimo | Celery + Redis + worker + beat | Bull + Redis + cron | Quartz config + @EnableScheduling |
import + goroutine | @cron/@background |
| Broker externo requerido | ✅ Redis/RabbitMQ | ✅ Redis | ❌ in-memory | ❌ in-memory | ❌ in-memory |
| Spawn tipado | ❌ string task names | ⚠ TS opcional | ❌ reflection | ❌ closures sin marca | ✅ @background + checker |
| Validación estática del shape | ❌ runtime | ❌ runtime | ❌ reflection | N/A | ✅ checker |
| Cron syntax con seconds | ⚠ Celery beat schedule | ✅ con plugin | ⚠ Quartz cron 7 fields | ✅ con flag | ✅ ⅚/7 fields auto |
| Timezone IANA | ✅ con setup celery_beat_schedule |
⚠ con node-schedule | ✅ con setup | ⚠ con time.LoadLocation |
✅ tz="IANA/Name" kwarg |
| Retry con backoff | ✅ con setup autoretry_for |
✅ Bull retry config | ⚠ con Spring Retry lib | ❌ manual | ✅ retry={...} kwarg |
| Persistencia + visibility | ✅ Flower UI + DB | ✅ Bull UI + Redis | ⚠ con setup | ❌ | ✅ store=db + Postgres |
| Sin pip install ni cargo add | ❌ | ❌ | ❌ | ❌ deps | ✅ 0 deps |
| Compila a binario standalone | ❌ | ❌ | ✅ jar | ✅ | ✅ fitz build |
El diferencial mayor: persistencia opt-in con un kwarg.
Levantás Postgres (ya lo tenés para tu API), agregás store=db
al @cron, y al boot del binario las tablas fitz_cron_jobs /
fitz_cron_runs se crean automáticas. Inspeccionás con psql
sin más infra.
Paso 1 — @cron("expr"): tareas periódicas¶
@cron("0 3 * * *") // todos los días a las 3 AM (5 fields)
fn cleanup() -> Null {
print("[cron] cleanup nocturno")
return null
}
@cron("*/5 * * * * *") // cada 5 segundos (6 fields con seconds)
async fn heartbeat() -> Null {
return null
}
Sintaxis aceptada del cron expression:
| Fields | Layout | Ejemplo |
|---|---|---|
| 5 | min hora día mes día-semana |
"0 3 * * *" (3 AM cada día) |
| 6 | sec min hora día mes día-semana |
"*/5 * * * * *" (cada 5 seg) |
| 7 | sec min hora día mes día-semana año |
"0 0 0 1 1 * 2027" |
El runtime normaliza 5→6 automático (prepende "0 " como
segundo). 7 fields es para casos raros donde querés agendar
algo en un año específico.
Lo que valida el checker estáticamente:
- 1 arg posicional
Str(el cron expression). - Sin params en la fn — los jobs no reciben input.
- Return
Null,Result<Null>oFuture<Null>— el runtime descarta el valor; usáResultsi querés loguear errores. - No combinable con
@get/@post/@put/@delete/@ws/@background/@auth_provider/@test.
Smoke MVP — corré el archivo y dejalo unos segundos:
$ cat tick.fitz
@cron("*/2 * * * * *")
fn tick() -> Null {
print("[cron] tick")
return null
}
print("scheduler arrancado")
$ fitz run tick.fitz
scheduler arrancado
🕐 Fitz scheduler arrancado con 1 job(s) cron
@cron tick (*/2 * * * * *)
[cron] tick
[cron] tick
[cron] tick
^C
Cron-only mode: si el programa solo tiene @cron (sin
@server, sin @ws, sin handlers HTTP), el main queda vivo
bloqueante hasta Ctrl+C — modo systemd-friendly. Compilás con
fitz build, lo ponés como ExecStart=/usr/local/bin/cleanup
en un service unit, y listo: cero scripts extra, cero intérpretes
embebidos.
Paso 2 — @background y spawn(...): fire-and-forget¶
A veces querés disparar trabajo desde un handler HTTP sin que el handler espere al resultado. Caso típico: registrar analytics de un click, mandar un email, invalidar cache. El cliente no necesita esperar a eso para recibir su response.
Fitz lo trae con dos piezas coordinadas:
// 1. @background MARCA la fn como autorizada para spawn.
// Es opt-in del autor — sin esto, el checker rechaza el spawn.
@background
async fn track_click(slug: Str) -> Null {
let _ = sleep(100).await // simula I/O lento
print("[bg] click registrado: /{slug}")
return null
}
@get("/go/{slug}")
fn redirect(slug: Str) -> Str {
// 2. spawn(fn_call) lanza la task fire-and-forget.
// Devuelve Future<T>; descartado con `let _` = task detached.
let _ = spawn(track_click(slug))
return "→ url-del-slug"
}
Lo que pasa:
- Cliente llama
GET /go/fitz. - El handler ejecuta
spawn(track_click("fitz"))y obtiene unFuture<Null>. - Lo descarta con
let _ = ...→ la task queda detached ejecutándose en otro task tokio. - El handler retorna
"→ url-del-slug"inmediato, sin esperar el sleep + print. - Después de 100ms, el background imprime el log.
Lo que valida el checker estáticamente:
spawn(target_call)exige quetargetesté declarada con@background.- Si no, error claro:
async fn enviar(to: Str) -> Null { ... } // sin @background
@get("/x")
fn x() -> Str {
let _ = spawn(enviar("ada")) // ❌
return "ok"
}
✗ archivo.fitz — 1 error(es) de tipo:
Error en línea 5:18 — spawn: la fn `enviar` no está declarada
con `@background`. Marcá la fn con `@background\nfn
enviar(...) { ... }` para autorizar su ejecución
fire-and-forget vía spawn.
- El target del
spawn(...)debe ser un call literal a fn con nombre.spawn(x)conxvariable,spawn(obj.method()),spawn(closure())— todos rechazados. El target debe ser estáticamente claro.
spawn(...).await si querés esperar el resultado (caso
raro):
Paso 3 — Iter2: timezone con tz="IANA/Name"¶
Sin tz, la cron expression se interpreta en UTC. Eso casi
siempre es lo que NO querés. "0 9 * * *" no es "9 AM en mi
zona" — es 9 AM UTC, lo cual en BsAs es 6 AM y en Madrid es 10
AM.
Para alinear con el huso real del operador, pasás tz con un
nombre IANA:
@cron("0 9 * * *", tz="America/Argentina/Buenos_Aires")
fn reporte_diario() -> Null {
print("[cron] reporte 9 AM hora BsAs")
return null
}
@cron("0 14 * * 1-5", tz="Europe/Madrid")
fn aviso_oficina() -> Null { return null }
Husos aceptados: cualquier nombre IANA válido. La lista
canónica está en https://en.wikipedia.org/wiki/List_of_tz_database_time_zones.
Ejemplos comunes:
"UTC"(default si omitístz)."America/Argentina/Buenos_Aires"."America/New_York"."Europe/Madrid"."Asia/Tokyo".
Validación: el huso se valida al boot del scheduler. Nombre IANA inválido (typo, zona vieja) → error claro al arrancar, no espera al primer tick.
Detalle técnico: el runtime usa chrono-tz con la base IANA
embebida en el binario. Los cambios de DST (horario de verano)
se aplican automáticamente — "0 9 * * *" en Madrid es 9 AM
todo el año aunque el offset UTC oscile entre +1 y +2.
Paso 4 — Iter2: retry con backoff¶
Cuando un job falla (devuelve Err(...) o paniquea), por
default no se reintenta — un solo intento. Pero los jobs reales
típicamente tocan recursos externos (API HTTP, DB transient,
cola de mensajes) que bouncean. Reintentar con backoff es
patrón estándar.
@cron("*/10 * * * * *",
retry={max: 3, backoff: "exponential", initial_secs: 1, max_secs: 30})
async fn sync_externa() -> Result<Null> {
// Si esto Err-ea, el runtime reintenta hasta 3 veces más
// con delays 1s, 2s, 4s (capeado a 30s).
let _ = sleep(50).await
return Ok(null)
}
Forma canónica del retry (Map con 4 keys):
retry = {
max: 3, // Int: reintentos extra (no incluye el primer intento)
backoff: "exponential", // Str: "exponential" | "linear" | "constant"
initial_secs: 1, // Int: delay base en segundos
max_secs: 30 // Int: cap superior del delay
}
Backoff kinds:
| Kind | Cálculo del delay (segundos) | Cuándo usar |
|---|---|---|
"exponential" |
initial_secs * 2^(attempt-1) |
Caso default — upstream que puede bouncear (API HTTP, DB) |
"linear" |
initial_secs * attempt |
Cargas distribuidas predecibles |
"constant" |
initial_secs |
Polling, retry simple |
Todos capeados por max_secs. Si initial_secs=1, max_secs=30
con exponential, los delays serían 1, 2, 4, 8, 16, 30, 30, 30,
... (saturado).
Sin retry (default) — un solo intento, si falla queda registrado pero no se reintenta. La política conservadora es la correcta para muchos casos (jobs no-idempotentes, jobs que es mejor que un humano vea el error y decida).
El runtime persiste cada attempt si tenés store=db (paso 6).
Sin store, los attempts quedan solo en stderr.
Paso 5 — Iter2: catch_up policy¶
Caso típico: el proceso estuvo abajo durante uno o más ticks programados. Cuando arranca, ¿qué hace con los ticks perdidos?
catch_up=false(default): skip silencioso. El scheduler arranca y espera el próximo tick natural. Los ticks perdidos se ignoran.catch_up=true: ejecuta UN run inmediato al boot si el últimolast_run_atregistrado está atrasado. No N runs (eso sería spam) — uno solo para "ponerse al día".
@cron("0 */15 * * *", // cada 15 min
catch_up=true,
store=db) // catch_up requiere store
async fn sync_periodico() -> Result<Null> {
print("[cron] sync (con catch_up al boot si missed)")
return Ok(null)
}
Cuándo usar catch_up=true:
- El job es idempotente (correrlo dos veces no rompe nada).
- Querés garantizar que al menos un run reciente existe cuando arranca el proceso (típico: cleanup, sync, refresh de cache).
Cuándo dejarlo en false (default):
- El job NO es idempotente.
- "Perder un tick" está OK (caso típico: heartbeat metrics, log rotation que sí o sí va a llegar el próximo tick).
catch_up=true requiere store=db para saber last_run_at.
Sin store, no hay history.
Paso 6 — Iter2: store=db persistencia sobre Postgres¶
El kwarg más jugoso. Persiste el registry + cada run en dos tablas Postgres:
let db = db.connect(
env_or("DATABASE_URL", "postgres://postgres:secret@localhost:5432/postgres?sslmode=disable")
).await
@cron("*/10 * * * * *",
tz="America/Argentina/Buenos_Aires",
retry={max: 3, backoff: "exponential", initial_secs: 1, max_secs: 30},
catch_up=true,
store=db)
async fn heartbeat() -> Result<Null> {
print("[cron] heartbeat tick")
return Ok(null)
}
@get("/health")
fn health() -> Str { return "ok" }
@server(43931, docs=false)
fn main() => 0
Lo que pasa al arrancar el scheduler:
- El runtime ejecuta
CREATE TABLE IF NOT EXISTSpara las dos tablas:
CREATE TABLE IF NOT EXISTS fitz_cron_jobs (
name TEXT PRIMARY KEY,
schedule TEXT NOT NULL,
tz TEXT NOT NULL,
last_run_at TIMESTAMPTZ,
last_status TEXT,
last_error TEXT,
next_run_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS fitz_cron_runs (
id BIGSERIAL PRIMARY KEY,
job_name TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ,
status TEXT NOT NULL, -- 'running' | 'ok' | 'failed' | 'retrying'
attempt INTEGER NOT NULL, -- 1-indexed
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_cron_runs_by_job
ON fitz_cron_runs (job_name, started_at DESC);
- Hace
INSERT ... ON CONFLICT (name) DO UPDATEdel registry — el nombre del job + su cron expression actual + su tz. - Si
catch_up=true, miralast_run_aty decide si dispara un run inmediato. - Entra al loop normal: por cada tick,
INSERT INTO fitz_cron_runs (status='running'), ejecuta el handler,UPDATE ... SET status='ok'/'failed'/'retrying'.
Visibility con psql:
-- Último estado de cada job.
SELECT name, schedule, tz, last_run_at, last_status, last_error
FROM fitz_cron_jobs;
-- Últimas N ejecuciones (incluye attempts de retry).
SELECT job_name, started_at, finished_at, status, attempt, error
FROM fitz_cron_runs
ORDER BY id DESC
LIMIT 20;
-- Jobs que fallaron en las últimas 24 horas.
SELECT job_name, started_at, status, attempt, error
FROM fitz_cron_runs
WHERE status IN ('failed', 'retrying')
AND started_at > NOW() - INTERVAL '24 hours'
ORDER BY started_at DESC;
Detalle del binding db:
db.connect(...).await retorna Result<DbConn> (la conn puede
fallar al arrancar). Como ? no está soportado a top-level del
archivo, el binding queda como Result<DbConn> puro.
El runtime/codegen desempaca automáticamente vía el trait
__FitzCronStoreFrom: si la conn falló, panea con mensaje claro
al arrancar el scheduler. Equivale al expect() que escribirías
a mano.
Paridad
fitz run↔fitz build(desde v0.37.3) —@cron(..., store=db)funciona igual confitz runque confitz build, en ambos modos (cron-only y HTTP+cron). El intérprete corre el eval y el scheduler/servidor sobre un único runtime tokio compartido, así que la conexión DB que abrís al boot sigue viva cuando el scheduler crea las tablas. (Antes de v0.37.3 había un bug de lifecycle del runtime del intérprete que dropeaba la conexión antes de arrancar el scheduler y solo dejaba correrstore=dbvíafitz build— ya está cerrado.)
Paso 7 — Combinando todo: la receta de producción¶
let db = db.connect(env_or("DATABASE_URL", "...")).await
// Cron principal del negocio: sync con API externa, con retry y
// persistencia. Pierde tick si está abajo (no idempotente).
@cron("0 */15 * * * *", // cada 15 min
tz="America/Argentina/Buenos_Aires",
retry={max: 3, backoff: "exponential", initial_secs: 2, max_secs: 60},
store=db)
async fn sync_orders() -> Result<Null> {
// ... HTTP call externa, INSERT en DB ...
return Ok(null)
}
// Cleanup nocturno: idempotente, con catch_up al boot.
@cron("0 0 3 * * *", // 3 AM
tz="America/Argentina/Buenos_Aires",
catch_up=true,
store=db)
async fn cleanup_old_sessions() -> Result<Null> {
// ... DELETE FROM sessions WHERE created_at < NOW() - INTERVAL '7 days' ...
return Ok(null)
}
// Heartbeat metrics: si pierde tick, no pasa nada (no store).
@cron("0 * * * * *") // cada minuto
fn heartbeat() -> Null {
// ... emitir métricas a Prometheus / Datadog ...
return null
}
// Background del flow HTTP: tipado.
@background
async fn registrar_evento(user_id: Int, evento: Str) -> Null {
// ... INSERT INTO events ...
return null
}
@post("/api/click")
fn click(user_id: Int, evento: Str) -> Str {
let _ = spawn(registrar_evento(user_id, evento))
return "ok"
}
@server(8080)
fn main() => 0
3 jobs con políticas distintas, 1 background, 1
handler HTTP, una sola conn DB compartida, paridad bit-a-bit
fitz run ↔ fitz build. Sin Celery, sin Redis, sin
docker-compose extra.
Subset compilable a binario¶
| Feature | fitz run |
fitz build |
|---|---|---|
@cron("expr") ⅚/7 fields |
✅ | ✅ |
@background + spawn(...) tipado |
✅ | ✅ |
spawn(...).await para esperar resultado |
✅ | ✅ |
Cron-only mode (sin @server) |
✅ | ✅ |
@cron(..., tz="IANA/Name") |
✅ | ✅ |
@cron(..., retry={...}) con 3 backoffs |
✅ | ✅ |
@cron(..., catch_up=true) |
✅ | ✅ |
@cron(..., store=db) con Postgres |
✅ | ✅ |
@background con tz/retry |
✅ | ✅ |
@background(store=db) persiste spawns en fitz_bg_jobs (v0.37.7) |
✅ | ✅¹ |
@background(..., catch_up=true) marca huérfanos failed |
✅ | ✅ |
Tablas fitz_cron_jobs/fitz_cron_runs/fitz_bg_jobs auto-create |
✅ | ✅ |
| UI dashboard tipo Sidekiq Web | ❌ | ❌ |
| Coordinación multi-instancia (locks distribuidos) | ❌ | ❌ |
¹ En fitz build (v0.37.8, port de B20), @background(store=db)
persiste cuando el worker se declara en el main O en un módulo
importado (co-localizado con su let db = ...), con el spawn(...)
en el main. Lo único diferido es el spawn(...) ubicado dentro de un
módulo; en fitz run persiste desde cualquier lado (registry global).
Validación¶
-
@cron("*/2 * * * * *")sobre fn sync dispara cada 2 segundos (output verbatim en stdout). - El log al boot del scheduler dice
🕐 Fitz scheduler arrancado con N job(s) cron. -
@croncon kwarg desconocido (e.g.oops="x") dispara error claro del checker citando "Aceptados:tz,retry,catch_up,store". -
spawn(fn_sin_background)dispara error del checker: "la fn no está declarada con@background". -
spawn(call_no_literal)(variable, method call, closure) dispara error del checker. - Cron-only mode (sin
@server) queda vivo hastaCtrl+C. -
tz="IANA/Name"inválido (typo) → error al arrancar el scheduler, no al primer tick. -
retry={max: 3, ...}con backoff exponential aplica delaysinitial_secs * 2^(attempt-1), capeados pormax_secs. -
store=dbarranca → las tablasfitz_cron_jobsyfitz_cron_runsexisten en Postgres (verificable conpsql -c "\dt fitz_cron_*"). - Cada tick produce un INSERT en
fitz_cron_runsconstatusque transicionarunning → ok/failed. -
catch_up=trueconstore=dbejecuta UN run extra al boot silast_run_atestá atrasado (no N). -
fitz builddel programa con@cron + @background + spawn + store=dbproduce binario standalone. - El binario nativo cron-only (sin HTTP) responde a
Ctrl+Ccon cleanup limpio (no zombies).
Troubleshooting¶
@cron sobre fn 'X': kwargoopsno reconocido. Aceptados: tz, retry, catch_up, store¶
Typo en el nombre del kwarg. Los aceptados son solo: tz,
retry, catch_up, store.
@cron sobre fn 'X': debe tener 1 arg posicional (Str, la cron expression)¶
Llamaste @cron() sin args, o pasaste algo que no es Str, o
pasaste 2+ args posicionales.
Fix: un solo arg posicional Str:
spawn: la fnXno está declarada con @background¶
Olvidaste marcar la fn target. El opt-in es necesario — sin él, el checker rechaza el callsite estáticamente.
Fix:
spawn: el target debe ser un call literal a una fn @background¶
Pasaste a spawn(...) algo que no es un call literal:
Fix: llamada directa a fn con nombre:
El cron NO dispara aunque la expression parece OK¶
Verificá:
- La expression está bien tipeada (
*/5 * * * * *con espacios, no*/5*****). tz(si lo pusiste) es un nombre IANA válido.- El proceso no terminó antes del primer tick —
fitz runen cron-only mode queda vivo hastaCtrl+C; si el último stmt del archivo esreturn nullo similar, el programa puede terminar antes de que llegue el tick. Fix: dejar unprint(...)final o agregar@serverpara mantener el runtime vivo. - Tu reloj de sistema está OK. El scheduler usa
chrono::Utc::now()+ el offset detz.
fitz run con store=db — ¿anda?¶
Sí, desde v0.37.3. Si estás en una versión anterior y ves un
pánico al arrancar el scheduler ("A Tokio 1.x context was found,
but it is being shutdown" al crear fitz_cron_jobs), es el bug
de lifecycle del runtime del intérprete que se cerró en v0.37.3:
actualizá fitz o, como puente, corré el programa con fitz build
(el binario nativo nunca tuvo el problema).
fitz_cron_runs se llena demasiado rápido¶
Si tu @cron corre cada 2 segundos con store=db, vas a
acumular ~43k rows por día. Para producción, considerá:
- Subir el intervalo del cron a algo realista (cada minuto vs cada 2 segundos).
- Configurar un job de cleanup que borra rows viejas:
@cron("0 0 4 * * *", store=db) // diario 4 AM
async fn cleanup_runs() -> Result<Null> {
let _ = db.exec(
"DELETE FROM fitz_cron_runs WHERE started_at < NOW() - INTERVAL '30 days'",
[]
).await
return Ok(null)
}
- Particionar
fitz_cron_runsporstarted_atcon declarative partitioning de Postgres (out of scope del cap, pero estándar de DBA).
El tz IANA inválido → cómo lo veo¶
El error sale al arrancar el scheduler:
🕐 Fitz scheduler arrancado con 1 job(s) cron
panic: timezone 'America/Argentina/Cordoba' no es un nombre IANA
válido. Lista canónica en
https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
Verificá la lista canónica. Errores comunes: poner espacios en
vez de _, abreviaciones ("BsAs" no es IANA), zonas viejas
deprecadas.
Job que fallaba en attempt 1 ahora retry pero el delay parece raro¶
Calculá manual con la fórmula del backoff y comparalo:
exponential: attempt 1 inicial, attempt 2 después deinitial_secs * 2^0, attempt 3 después deinitial_secs * 2^1, etc. Total:1, 2, 4, 8, 16, ...desdeinitial_secs=1.linear:initial_secs, 2*initial_secs, 3*initial_secs, ...constant: siempreinitial_secs.
Todos capeados por max_secs. Si el delay observado supera
max_secs, hay un bug del scheduler (raro post-iter2) —
abrir issue con el log del scheduler.
El @cron de cleanup_old_sessions corre 3 veces al boot¶
Tenés catch_up=true Y un proceso que se reinicia 3 veces
seguidas. Cada boot mira last_run_at y dispara UN run. Si
reinciás rápido, esos 3 runs salen.
Fix: en producción, el proceso debería ser estable. Si
estás iterando localmente, podés desactivar catch_up
temporalmente.
Qué viene en M5.C5 — HTTP client outbound¶
Te falta una pieza del stack web entero antes de cerrar M5: hacer
requests salientes desde Fitz a APIs externas. Webhooks
salientes, health checks de servicios externos, proxying a
upstream APIs, scraping. Todo eso necesita un cliente HTTP — y
Fitz lo trae built-in del lenguaje (módulo http), paralelo
a cómo db/jwt/hash/log son builtins.
El próximo cap cubre:
- Los 6 builtins (
http.get/head/post/put/delete/request) con paridad bit-a-bitfitz run↔fitz build. - 3 body shapes nativos (
Strraw /Map<Str,Any>auto-JSON /Bytesoctetos). - Modelo de errores: transporte →
Result::Err, 4xx/5xx →Okconr.status. - Integración con todo lo visto en M5: webhook dispatcher con
@background + spawn(...), health checker con@cron, y un capstone integrador.
Arrancá con M5.C5 — HTTP client outbound.