Tema
Connettore: HTTP (API REST per i nodi lookup e action)
Parla con un'API REST dentro il grafo: legge con httpLookup in un nodo lookup, scrive con httpAction in un nodo action. I due condividono trasporto, configurazione e regole sugli errori; cambia che cosa sono autorizzati a fare.
Letture — httpLookup
Arricchisce i record leggendo da un'API REST, in sola lettura, dentro un nodo lookup. È la versione HTTP di postgresLookup: l'IO resta visibile nel grafo invece che nascosto in una transform. Codice in src/connectors/http/.
| Funzione | Tipo | Cosa fa |
|---|---|---|
httpLookup | LookupResolver | Risolve un batch di chiavi con un'API REST |
httpAction | Action | Scrive su un'API REST e prosegue con l'esito |
loadHttpRegistry | registry | Le API configurate, per istanza |
loadHttpConfig | config | Una sola API, da variabili con prefisso (HTTP_) |
loadHttpInstance | config | Una sola istanza del registry, senza le altre |
istanzaHttpPerIlPiano | piano | L'istanza per il piano, con la sua ricaduta |
richiestaHttp | trasporto | Una richiesta JSON, per gli altri connettori REST |
rispostaErroreHttp | trasporto | Status, header e corpo di una risposta di errore |
Configurazione
loadHttpRegistry(env?) risolve le istanze: con HTTP_INSTANCES=gestionale ogni API ha le sue HTTP_<ID>_*, senza lista valgono le variabili base come istanza default. Per etl env check, istanzaHttpDichiarata(id) è l'istanza da passare ad ambienteDellaPipeline nell'environment della pipeline, con le credenziali della variante di AUTH che il piano non mostra.
| Variabile | Default | Note |
|---|---|---|
HTTP_BASE_URL | — | obbligatoria; http o https, senza credenziali nell'URL |
HTTP_AUTH | none | none / bearer / header / basic |
HTTP_TOKEN | — | con bearer: Authorization: Bearer <token> |
HTTP_HEADER_NAME | — | con header: il nome dell'header, per esempio X-Api-Key |
HTTP_API_KEY | — | con header: il valore |
HTTP_USERNAME | — | con basic |
HTTP_PASSWORD | — | con basic |
HTTP_TIMEOUT_MS | 10000 | tempo massimo di una richiesta, in millisecondi |
HTTP_MAX_RESPONSE_BYTES | 10485760 | dimensione massima di una risposta (10 MiB) |
Le variabili sono dichiarate in MANIFEST_HTTP: HTTP_AUTH sceglie la variante, e si leggono solo le variabili di quella. Con variabili mancanti il ConfigError le elenca tutte, anche nel campo mancanti. loadHttpInstance(id) carica una sola istanza senza le altre, e istanzaHttpPerIlPiano(id, env) è la stessa istanza per il piano, che ricade sull'esempio da sola.
Gli errori di configurazione nominano le variabili, mai i valori. HTTP_HEADER_NAME deve essere un nome di header valido e diverso da accept, content-type, content-length e host: gli header del protocollo li imposta il connettore.
Due modalità
batch: una richiesta per tutto il batch di chiavi del nodo, dove l'API sa cercarne molte insieme. GET con le chiavi in query, oppure POST di ricerca con le chiavi nel body.
ts
const gestionale = loadHttpRegistry().get("gestionale");
.lookup<Riga, RigaConCliente>("righe", "cliente", {
resolver: httpLookup({
mode: "batch",
config: gestionale,
instanceId: "gestionale",
method: "GET",
path: "/clienti",
request: (keys) => ({ query: { codice: keys } }), // ?codice=A&codice=B
response: z.object({ clienti: z.array(clienteSchema) }),
rows: (r) => r.clienti,
keyOf: (cliente) => cliente.codice,
normalizeKey: (k) => k.trim().toUpperCase(), // facoltativa, ai due lati
}),
key: (r) => r.codiceCliente,
merge: (rows, r) => ({ ...r, cliente: rows[0] ?? null }),
}, { deadLetter: scarti })per-key: una GET per chiave, per le API che espongono solo la risorsa singola. Un 404 vale «non trovato»; al più concurrency richieste insieme (default 4).
ts
httpLookup({
mode: "per-key",
config: gestionale,
instanceId: "gestionale",
path: (id) => `/ordini/${id}`, // la chiave arriva già codificata per l'URL
response: ordineSchema,
rows: (ordine) => [ordine], // facoltativa: default la risposta come riga unica
concurrency: 4,
});In entrambe le modalità le righe tornano sulla chiave esattamente come è stata passata (contratto di LookupResolver): un servizio che risponde in maiuscolo non trasforma una chiave minuscola in un non-trovato silenzioso, se normalizeKey lo dichiara.
Errori
| Caso | Esito |
|---|---|
| 2xx con JSON conforme allo schema | righe |
404 in per-key | non trovato (onNotFound del nodo decide) |
| 429, 502, 503, 504 | LookupError transitorio, Retry-After |
altro 4xx, 5xx, 404 in batch | LookupError con status |
| timeout, servizio non raggiungibile | LookupError transitorio |
| corpo non JSON o non conforme allo schema | LookupError con i percorsi dei campi errati |
risposta oltre HTTP_MAX_RESPONSE_BYTES | LookupError |
chiave . o .. in per-key | LookupError, nessuna richiesta |
| abort della run | l'abort si propaga, non è un LookupError |
Un LookupError segue la semantica del nodo: dead-letter se c'è, ramo catch sotto un tryCatch, altrimenti fail-fast. In per-key un errore fa fallire tutto il batch del nodo, come un round-trip SQL: con batchSize: 1 l'errore resta sul singolo record.
In per-key, al primo errore le richieste ancora in volo vengono annullate e le chiavi rimaste non partono.
Retry e rate limit. Gli errori transitori si ritentano con la policy retry del nodo lookup, che rifà il round-trip; un Retry-After (secondi o data) allunga l'attesa fino a maxMs. httpLookup accetta anche un rateLimit: { perSecond } proprio, che conta le singole richieste: in per-key un round-trip ne fa molte, e il rateLimit del nodo non basterebbe.
Niente dati negli errori. Il messaggio porta metodo, endpoint con {chiave} al posto della chiave e status: mai la chiave, il corpo della risposta o i valori rifiutati dallo schema, perché finiscono nel report e una chiave può essere un'email o un codice fiscale.
Descriptor
Kind http-lookup, con l'istanza (connection), la base (origine e path, senza query), la richiesta (GET /ordini/{chiave}), la modalità e in fondo la credenziale. Mai token, header di autenticazione o password.
La base porta la variabile da cui arriva (HTTP_BASE_URL) e la sua origine. La credenziale dice la variante di HTTP_AUTH e il suo stato, mai il valore né il nome dell'header:
HTTP_AUTH | Riga credenziale |
|---|---|
assente o none | nessuna |
bearer | token · impostato |
header | API key · impostata |
basic | password · impostata |
| incompleta | la variante e le variabili che mancano, origine mancante |
| sconosciuta | HTTP_AUTH non valido (attesi: …) |
Limiti
- Niente paginazione in
batch: ilbatchSizedel nodo deve stare in una risposta. - In
batchconGETle chiavi vanno nell'URL: con ilbatchSizedi default (200) un servizio può rispondere 414 (URL troppo lungo). Meglio unPOSTdi ricerca, o unbatchSizepiù piccolo. - In
per-key, se lo schema della risposta è un array, un array vuoto è comunque una riga («trovato»): per contare come non trovato va estratto conrows: (lista) => lista. - Il path in
per-keyriceve la chiave codificata e non può uscire dal prefisso della base: un path che, risolto, esce dal prefisso è unLookupError. httpLookupresta di sola lettura: una scrittura èhttpAction(sotto).
Scritture — httpAction
Aggiorna un sistema esterno e prosegue con la sua risposta: confermare un pagamento su un gestionale, creare una pratica, chiudere una lavorazione. È un nodo action, quindi la scrittura è visibile nella topologia e nel DAG invece di sparire in una fetch dentro una pipeline.
ts
.action("verificato", "aggiorna", httpAction({
config: registry.get("gestionale"),
instanceId: "gestionale",
method: "POST",
path: "/aggiorna-ordine",
request: (r) => ({ body: { numeroOrdine: r.numero, importoPagato: r.importo } }),
response: z.object({ esito: z.enum(["OK", "KO"]), messaggio: z.string().optional() }),
merge: (r, esito) => ({ ...r, esito }),
}))method accetta solo i verbi di scrittura (POST, PUT, PATCH, DELETE): per leggere c'è httpLookup, che mette in comune le chiavi di un batch e ha una cache per run. path è configurazione e non dipende dal record — è ciò che rende l'endpoint leggibile nel DAG e sicuro nei messaggi d'errore.
L'esito è un dato
merge riceve un'unione discriminata:
ts
{ ok: true, status, body } // risposta 2xx, validata con lo schema
{ ok: false, status?, motivo } // fallita; `status` manca se non è mai arrivataUn esito.ok === true con body.esito === "KO" non è un errore: è il gestionale che risponde «non ci riesco». La distinzione fra «non ho potuto parlargli» e «mi ha detto di no» sta tutta qui, e di solito il grafo la usa con un route.
⚠️ Un ok: false con status valorizzato non vuol dire che la scrittura non sia avvenuta. Lo status c'è solo quando il servizio ha risposto: un 500 dopo aver aggiornato, o un 200 con un corpo fuori contratto, sono fallimenti per noi ma possono essere scritture andate a buon fine dall'altra parte. Senza status la richiesta non è arrivata (rete, timeout, DNS) e la scrittura quasi certamente non c'è stata. Chi progetta il ramo di recupero deve trattare i due casi in modo diverso: il primo va a un umano, il secondo si può rimettere in coda.
Un corpo vuoto (204 No Content, tipico dopo un DELETE) vale null: lo accetta uno schema z.null(), lo rifiuta uno z.object come qualsiasi altra risposta fuori contratto.
Su errore: fermarsi o proseguire
onError | Cosa succede |
|---|---|
"fallisci" | Default. Lancia un ActionError: dead-letter del nodo, o ramo catch. |
"continua" | L'esito ok: false arriva a merge e il grafo prosegue. |
⚠️ Con "continua" il ramo che riceve un esito fallito deve fare qualcosa di visibile — notificare, contare, instradare in revisione. Altrimenti un servizio spento diventa silenzio, che è peggio di una run rossa.
Retry: solo se lo dichiari
Ritentare una scrittura la ripete. Il framework non può sapere se il servizio la riconosce come già fatta, quindi non lo indovina: una retry senza idempotente: true è un ConfigError alla costruzione del grafo, non un problema che salta fuori in produzione. È la stessa regola di Sink.retrySafe.
Dichiara idempotente: true solo quando è vero: il servizio deduplica su una chiave naturale, o la richiesta è un PUT che sostituisce una risorsa intera. Con l'opt-in si ritentano solo gli errori transitori (429, 502, 503, 504, timeout, rete), mai un 400 o un 422.
Non esiste un Idempotency-Key generato dal connettore: la deduplicazione è del servizio, sulla chiave naturale che gli mandi. Per questo request viene valutata una volta sola, prima del primo tentativo, e ogni retry rimanda byte per byte lo stesso corpo: se generasse un id o un timestamp a ogni giro, il servizio vedrebbe richieste diverse e idempotente non proteggerebbe da niente.
Errori e descriptor
Valgono le regole delle letture: il messaggio porta metodo, endpoint e status, mai il corpo del record o della risposta. Il descriptor ha kind http-action, con istanza, base, richiesta (POST /aggiorna-ordine), comportamento su errore, se la scrittura è dichiarata idempotente e la stessa riga credenziale delle letture — mai token, header di autenticazione o password.
Trasporto condiviso — richiestaHttp
httpLookup e httpAction passano per lo stesso trasporto, ed è esportato dal barrel perché lo usino anche i connettori di dominio che parlano REST, a partire da quello di Jira: invece di riscriversi timeout, tetto sui byte, abort, validazione Zod e classificazione degli errori transitori, chiamano richiestaHttp. Le tabelle degli errori sopra valgono uguali.
ts
import { richiestaHttp, rispostaErroreHttp } from "../http/index.js";
const risposta = await richiestaHttp(
config, // un HttpConfig, dal registry dell'istanza
{
method: "POST",
path: "/rest/api/3/search/jql",
endpoint: "/rest/api/3/search/jql", // senza dati: finisce nei messaggi
body: { jql, fields },
headers: { "X-Atlassian-Token": "no-check" },
},
paginaSchema,
ctx,
{ chiamante: "lookup" }, // o "action": decide LookupError / ActionError
);
risposta.headers["x-ratelimit-remaining"]; // anche con kind "not-found"Rispetto a ciò che vedono httpLookup e httpAction aggiunge tre cose:
- Header per richiesta (
headers). Quelli del protocollo —accept,content-type,content-length,host— eauthorizationrestano del trasporto, come l'header di autenticazione dell'istanza (HTTP_HEADER_NAME). Chiederne uno, o un header non valido, è unConfigErrorche nomina l'header e mai il valore, e la richiesta non parte. - Header della risposta nell'esito, con i nomi in minuscolo: servono per leggere i limiti del servizio (
X-RateLimit-*,Retry-After) anche sulle risposte andate bene. - Risposta degli errori: status, header e corpo, se è JSON e sta sotto
HTTP_MAX_RESPONSE_BYTES. Si legge solo da codice, conrispostaErroreHttp(err), che segue la catena dellecause; un corpo vuoto, non JSON o troppo grande lasciacorpoassente, e l'errore resta quello di status.httpLookupehttpActionnon la leggono: scartano il corpo dell'errore senza leggerlo, come hanno sempre fatto.
La risposta non è nell'errore. Il messaggio resta metodo, endpoint e status, e details non cambia: la risposta sta fuori dall'oggetto errore, quindi né il report, né il log, né un JSON.stringify la portano. Il connettore che la legge ne prende ciò che gli serve — per Jira i nomi dei campi rifiutati, le chiavi di errors — e decide dove metterlo: i testi del servizio vanno solo nell'esito dato a merge, mai in un messaggio. Va letta dove si cattura l'errore del trasporto: gli errori che il runner ricostruisce (in una dead-letter, per esempio) non la portano.

