`Promise.all` non basta: `allSettled`, `any` e `AbortSignal.timeout`

Sei chiamate in parallelo a sei endpoint diversi. Cinque rispondono, una va in errore. Il codice
scritto con Promise.all ti consegna l’errore e basta: i cinque risultati buoni non li vedi mai.
const [meteo, cambio, spedizioni, magazzino, listino, news] = await Promise.all([
getMeteo(), getCambio(), getSpedizioni(), getMagazzino(), getListino(), getNews(),
]);
Se getNews() rigetta, l’intera destrutturazione salta e la pagina resta vuota — anche se il
magazzino e il listino, gli unici due dati che servivano davvero, erano già arrivati.
Cosa fa davvero Promise.all quando qualcosa fallisce
Vale la pena leggere la definizione per intero, perché la seconda metà è quella che sorprende.
MDN descrive Promise.all() così: la promise restituita «fulfills when all of the input’s promises
fulfill (including when an empty iterable is passed), with an array of the fulfillment values»,
mentre «it rejects when any of the input’s promises rejects, with this first rejection reason».
Fin qui nessuna sorpresa. La riga che conta è quella dopo:
Rejecting the returned promise does not cancel the remaining operations or unsubscribe the
handlers attached to their promises.
Tradotto: il rigetto non annulla niente. Le altre cinque richieste HTTP partono lo stesso, viaggiano
lo stesso e arrivano lo stesso — semplicemente non hai più modo di leggerne il risultato. Non hai
risparmiato banda né tempo server: hai solo perso la visibilità sul lavoro che stavi già pagando.
C’è un secondo effetto, documentato anch’esso: «Promise.all() immediately marks all promises as
“handled” when it is called (by calling their .then() methods). Subsequent rejections after the
first rejection will be ignored, and will not trigger any unhandledrejection events». Se tre delle
sei falliscono, nei log ne vedi una. Le altre due spariscono in silenzio.
Promise.all resta la scelta giusta quando i task sono davvero interdipendenti e senza uno di loro
il risultato non esiste. Negli altri casi stai usando lo strumento sbagliato.
allSettled: tutti i risultati, buoni e cattivi
Promise.allSettled non rigetta mai per colpa di un elemento. Si fulfilla quando tutte le promise
hanno settled, e il valore è un array di oggetti, «in the order of the promises passed, regardless
of completion order». Ogni oggetto ha status ("fulfilled" o "rejected") più value oppure
reason, mai entrambi.
const risultati = await Promise.allSettled([
getMagazzino(), getListino(), getNews(),
]);
const ok = risultati.filter((r) => r.status === "fulfilled").map((r) => r.value);
const ko = risultati.filter((r) => r.status === "rejected").map((r) => r.reason);
if (ko.length) console.warn(`${ko.length} sorgenti non disponibili`, ko);
render(ok);
L’ordine garantito è la parte comoda: risultati[1] è sempre il listino, anche se ha risposto per
ultimo. MDN indica quando serve — «when you have multiple asynchronous tasks that are not dependent
on one another to complete successfully, or you’d always like to know the result of each promise» —
che è la descrizione esatta di una dashboard con sei widget.
any: il primo che risponde bene, non il primo che risponde
Promise.any si fulfilla con il valore della prima promise che riesce e ignora i rigetti fino a
quel momento. Serve quando hai più strade per ottenere la stessa cosa: due mirror, una cache e
l’origine, due provider di geocoding.
La differenza con Promise.race è sottile e va tenuta a mente: «Unlike Promise.race(), which
returns the first settled value (either fulfillment or rejection), Promise.any() returns the
first fulfilled value». Con race, il mirror che risponde in 20 ms con un 500 vince sulla cache
che risponde in 50 ms con il dato giusto. Con any, no.
Se falliscono tutte, any rigetta con un AggregateError «containing an array of rejection reasons
in its errors property», sempre nell’ordine di partenza. Quel campo va loggato, altrimenti ti
resta un messaggio generico e zero indizi:
try {
const dati = await Promise.any([daCache(), daMirror(), daOrigine()]);
return dati;
} catch (err) {
console.error("tutte le sorgenti KO", err.errors); // array, non stringa
throw err;
}
Il timeout: AbortSignal.timeout(), non setTimeout
Nessuno dei tre combinatori mette un limite di tempo. Una promise che non si risolve mai tiene ferme
all e allSettled per sempre: il timeout va messo sull’operazione, non sull’attesa.
AbortSignal.timeout(time) restituisce un segnale che aborta da solo dopo time millisecondi, con
reason impostata a un TimeoutError DOMException. È un costruttore statico: niente
clearTimeout da ricordare, niente controller da tenere in una variabile. Baseline dall’aprile 2024
sui browser; su Node è disponibile da v17.3.0 (e v16.14.0), mentre AbortSignal.any() arriva con
v20.3.0 (e v18.17.0).
L’esempio di MDN distingue i casi che in produzione vanno separati davvero:
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
const result = await res.blob();
} catch (err) {
if (err.name === "TimeoutError") {
// This exception is from the abort signal
} else if (err.name === "AbortError") {
// This exception is from the fetch itself
} else if (err.name === "TypeError") {
// AbortSignal.timeout() method is not supported
}
}
TimeoutError è il tuo limite che è scaduto; AbortError è l’utente che ha chiuso la scheda o
premuto stop — quello è il default di controller.abort() chiamato senza argomenti. Trattarli allo
stesso modo significa riprovare automaticamente una richiesta che l’utente ha appena annullato.
Un dettaglio che si paga caro se lo si ignora: il timeout «is based on active rather than elapsed
time, and will effectively be paused if the code is running in a suspended worker, or while the
document is in a back-forward cache». Su una scheda in background il conteggio si ferma. È il
comportamento che vuoi — ma se stai misurando SLA reali, quello non è il punto da cui leggere i
numeri.
Per avere insieme scadenza e annullamento manuale c’è AbortSignal.any(iterable), che aborta quando
aborta uno qualsiasi dei segnali passati, con la reason del primo:
const controller = new AbortController(); // il bottone "Annulla"
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]);
await fetch(url, { signal });
Tre errori da evitare
- Usare
allper task indipendenti. Se i risultati non si servono a vicenda,allSettledti dà
gli stessi dati più quelli cheallavrebbe buttato. - Credere che il rigetto annulli le altre operazioni. Non le annulla. Se vuoi fermarle davvero
serve unAbortSignalcondiviso, passato a ognifetch. - Dimenticare l’iterabile vuoto.
Promise.all([])è già fulfillata;Promise.any([])è già
rigettata. Un array filtrato che resta vuoto manda in errore il ramoanysenza che nessuna
richiesta sia mai partita.
Fonti: la documentazione MDN di
Promise.all,
Promise.allSettled,
Promise.any,
AbortSignal.timeout()
e AbortSignal.any(),
più i globals di Node.js per le versioni.
