Gestire Chiamate API Concorrenti con Promise.allSettled(): La Resilienza che Promise.all() non offre

Intermedio
JavaScript

Scopri come Promise.allSettled() rivoluziona la gestione delle chiamate API concorrenti in JavaScript, offrendo robustezza e controllo superiore rispetto a Promise.all(), specialmente in scenari con operazioni indipendenti e potenziali fallimenti.

Pubblicato
Tag
Web Development javascript API fetch api promises concorrenza es2020 Error Handling Asincronicità

Introduzione: Il Cuore Asincrono del Web Moderno

Nel panorama dello sviluppo web contemporaneo, l'interazione con le API è una componente fondamentale di quasi ogni applicazione. Che si tratti di recuperare dati utente, aggiornare stati, inviare notifiche o integrare servizi esterni, le chiamate API sono il motore che alimenta l'esperienza dinamica che gli utenti si aspettano. Tuttavia, la gestione di queste chiamate, specialmente quando devono avvenire in parallelo, presenta sfide significative. La performance di un'applicazione web è spesso direttamente correlata all'efficienza con cui gestisce le operazioni asincrone, come il fetching di dati da più endpoint.

Immaginate un dashboard che deve caricare contemporaneamente il profilo utente, la lista degli ordini recenti, le notifiche e le statistiche di vendita. Attendere che ogni chiamata API si completi sequenzialmente sarebbe disastroso per l'esperienza utente, introducendo ritardi inaccettabili. La soluzione è eseguire queste chiamate in modo concorrente. JavaScript, con il suo modello di programmazione asincrona basato su Promise, offre strumenti potenti per affrontare questa sfida. Tra questi, Promise.all() è stato a lungo il cavallo di battaglia per raggruppare e gestire più Promise.

Ma cosa succede se una di queste chiamate API fallisce? Con Promise.all(), un singolo fallimento può compromettere l'intera operazione, impedendo di processare i risultati delle chiamate che invece hanno avuto successo. Questo comportamento "fail-fast" è spesso indesiderabile in scenari reali. È qui che entra in gioco Promise.allSettled(), un metodo più recente e robusto introdotto in ES2020, che offre una gestione più granulare e resiliente dei risultati, permettendo di processare sia i successi che i fallimenti di ogni singola Promise in un gruppo. Questo articolo esplorerà in profondità Promise.allSettled(), confrontandolo con Promise.all() e fornendo esempi pratici per sfruttarne appieno il potenziale nella programmazione web.

Comprendere l'Asincronicità e le Promise in JavaScript

Prima di addentrarci nelle specificità di Promise.all() e Promise.allSettled(), è fondamentale avere una solida comprensione dell'asincronicità in JavaScript e del ruolo delle Promise. JavaScript è un linguaggio single-threaded, il che significa che può eseguire una sola operazione alla volta. Se una richiesta di rete o un'operazione su disco bloccasse il thread principale, l'interfaccia utente diventerebbe non responsiva, congelando l'applicazione. Per ovviare a ciò, JavaScript utilizza meccanismi asincroni.

Una Promise è un oggetto che rappresenta il completamento (o il fallimento) eventuale di un'operazione asincrona e il suo valore risultante. Una Promise può trovarsi in uno dei seguenti stati:

  • Pending (In sospeso): Lo stato iniziale, né fulfilled né rejected.
  • Fulfilled (Risolta): L'operazione è stata completata con successo. Il valore è disponibile.
  • Rejected (Rifiutata): L'operazione è fallita. La ragione del fallimento è disponibile.

Una Promise che è passata da pending a fulfilled o rejected è considerata settled (stabilizzata). Una volta che una Promise è settled, il suo stato e il suo valore (o ragione) non possono più cambiare.

Le Promise sono la base per gestire operazioni asincrone in modo più strutturato e leggibile rispetto ai callback annidati (callback hell). Metodi come .then(), .catch(), e .finally() permettono di incatenare operazioni e gestire i risultati o gli errori in modo sequenziale o parallelo. L'introduzione di async/await ha poi reso la sintassi per lavorare con le Promise ancora più simile a quella sincrona, migliorando ulteriormente la leggibilità e la manutenibilità del codice asincrono.

Promise.all(): Il Potente ma Rigido Coordinatore

Promise.all() è un metodo statico di Promise che accetta un iterabile (tipicamente un array) di Promise e restituisce una nuova Promise. Questa nuova Promise si risolve quando tutte le Promise nell'iterabile si sono risolte con successo. Il valore di risoluzione è un array contenente i valori di risoluzione di ogni singola Promise, nell'ordine in cui sono state passate.

Ecco un esempio di come Promise.all() può essere utilizzato per effettuare più chiamate API in parallelo:

async function fetchDataWithPromiseAll() {
  const urls = [
    'https://jsonplaceholder.typicode.com/todos/1',
    'https://jsonplaceholder.typicode.com/posts/1',
    'https://jsonplaceholder.typicode.com/users/1'
  ];

  try {
    const responses = await Promise.all(
      urls.map(url => fetch(url).then(res => {
        if (!res.ok) {
          throw new Error(`HTTP error! status: ${res.status} from ${url}`);
        }
        return res.json();
      }))
    );
    
    console.log('Dati recuperati con successo da Promise.all():');
    responses.forEach((data, index) => {
      console.log(`Risposta ${index + 1}:`, data);
    });
  } catch (error) {
    console.error('Una o più chiamate API sono fallite con Promise.all():', error.message);
  }
}

fetchDataWithPromiseAll();

In questo esempio, Promise.all() attende che tutte e tre le chiamate fetch si completino con successo. Se tutte vanno a buon fine, stampa i dati recuperati. Questo è estremamente efficiente per raggruppare operazioni che devono tutte riuscire per considerare l'operazione complessiva un successo.

Il Limite di Promise.all(): Il Comportamento Fail-Fast

La principale limitazione di Promise.all() risiede nel suo comportamento "fail-fast". Se anche solo una delle Promise nell'iterabile viene rifiutata (ad esempio, a causa di un errore di rete, un timeout, o una risposta del server con codice di errore), la Promise restituita da Promise.all() verrà immediatamente rifiutata con la ragione del primo errore incontrato. Le altre Promise, anche se avrebbero potuto risolversi con successo, non verranno processate, e i loro risultati andranno persi o ignorati.

Consideriamo lo stesso scenario, ma introduciamo intenzionalmente un URL non valido per simulare un fallimento:

async function fetchDataWithPromiseAllWithError() {
  const urls = [
    'https://jsonplaceholder.typicode.com/todos/1',
    'https://jsonplaceholder.typicode.com/non-existent-endpoint/1', // Questo fallirà
    'https://jsonplaceholder.typicode.com/users/1'
  ];

  try {
    const responses = await Promise.all(
      urls.map(url => fetch(url).then(res => {
        if (!res.ok) {
          throw new Error(`HTTP error! status: ${res.status} from ${url}`);
        }
        return res.json();
      }).catch(error => {
        // Catch interno per non bloccare Promise.all() immediatamente,
        // ma il problema è che Promise.all() vuole che tutte le promises si risolvano.
        // Se una promise interna viene rifiutata, Promise.all() la cattura come rifiuto.
        // Per dimostrare il fall-fast, lasciamo che l'errore si propaghi.
        console.error(`Errore nel fetch di ${url}:`, error.message);
        throw error; // Rilancia l'errore per far fallire Promise.all()
      }))
    );
    
    console.log('Dati recuperati con successo da Promise.all():', responses);
  } catch (error) {
    console.error('Una o più chiamate API sono fallite con Promise.all():', error.message);
  }
}

fetchDataWithPromiseAllWithError();

Eseguendo questo codice, vedrete che l'intera operazione Promise.all() fallirà non appena la chiamata all'endpoint inesistente genera un errore. Anche se la chiamata per /todos/1 e /users/1 avrebbero potuto avere successo, i loro dati non verranno restituiti dall'operazione Promise.all() complessiva. Questo comportamento è problematico quando le operazioni sono indipendenti e si desidera comunque ottenere i risultati parziali.

Introduzione a Promise.allSettled(): La Soluzione Robusta

Promise.allSettled(), introdotto in ECMAScript 2020 (ES2020), è progettato per superare la limitazione "fail-fast" di Promise.all(). Anche Promise.allSettled() accetta un iterabile di Promise e restituisce una Promise. La differenza cruciale è che la Promise restituita da Promise.allSettled() si risolve solo quando tutte le Promise nell'iterabile sono state 'settled', ovvero sono state risolte o rifiutate.

Ciò significa che Promise.allSettled() non fallisce se una delle Promise interne viene rifiutata. Invece, restituisce un array di oggetti, ciascuno dei quali descrive il risultato di ogni singola Promise, indipendentemente dal fatto che sia stata risolta o rifiutata. Questo permette una gestione molto più robusta e granulare degli errori, essenziale in applicazioni complesse dove il fallimento di una singola operazione non dovrebbe necessariamente compromettere l'intera interfaccia utente o il flusso di lavoro.

Anatomia del Risultato di Promise.allSettled()

Il valore di risoluzione di Promise.allSettled() è un array di oggetti, dove ogni oggetto ha una proprietà status e una proprietà aggiuntiva a seconda dello stato:

  • Se una Promise è stata risolta (fulfilled), l'oggetto avrà:
    • status: 'fulfilled'
    • value: <il valore di risoluzione della Promise>
  • Se una Promise è stata rifiutata (rejected), l'oggetto avrà:
    • status: 'rejected'
    • reason: <la ragione del rifiuto della Promise>

Questa struttura dettagliata consente di ispezionare facilmente il risultato di ogni singola operazione e agire di conseguenza, gestendo i successi e gli errori in modo specifico.

Vediamo come appare il risultato con il nostro esempio precedente che includeva un endpoint fallimentare:

async function fetchDataWithPromiseAllSettled() {
  const urls = [
    'https://jsonplaceholder.typicode.com/todos/1',
    'https://jsonplaceholder.typicode.com/non-existent-endpoint/1', // Questo fallirà
    'https://jsonplaceholder.typicode.com/users/1'
  ];

  const results = await Promise.allSettled(
    urls.map(url => fetch(url).then(res => {
      if (!res.ok) {
        throw new Error(`HTTP error! status: ${res.status} from ${url}`);
      }
      return res.json();
    }).catch(error => {
      // Qui non è necessario rilanciare l'errore, 
      // Promise.allSettled() catturerà la Promise come 'rejected'.
      throw error; // La Promise interna viene rifiutata
    }))
  );

  console.log('Risultati da Promise.allSettled():', results);

  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      console.log(`
Risposta ${index + 1} (SUCCESS):`, result.value);
    } else {
      console.error(`
Risposta ${index + 1} (FAILURE):`, result.reason.message);
    }
  });
}

fetchDataWithPromiseAllSettled();

L'output di questo codice sarà molto più informativo. Vedrete un array di oggetti, dove il primo e il terzo avranno status: 'fulfilled' e conterranno i dati JSON, mentre il secondo avrà status: 'rejected' e conterrà un oggetto Error come reason. Questo ci permette di recuperare e utilizzare i dati delle chiamate riuscite, pur essendo consapevoli dei fallimenti.

Esempi Pratici con Promise.allSettled(): Costruire Dashboard Robuste

Immaginiamo di dover costruire una dashboard per un utente. Questa dashboard deve mostrare diverse sezioni indipendenti: il profilo dell'utente, i suoi ordini recenti, le sue notifiche e magari una sezione di notizie personalizzate. È fondamentale che la dashboard si carichi parzialmente anche se una o più di queste sezioni non riescono a caricarsi a causa di un problema API. Non vogliamo che l'intera pagina rimanga vuota o mostri un errore generico.

Utilizziamo Promise.allSettled() per gestire questo scenario:

// Funzioni simulate per chiamate API
const fetchUserProfile = async () => {
  console.log('Fetching user profile...');
  return new Promise(resolve => setTimeout(() => {
    resolve({ id: 1, name: 'Mario Rossi', email: 'mario.rossi@example.com' });
  }, 500));
};

const fetchRecentOrders = async () => {
  console.log('Fetching recent orders...');
  return new Promise(resolve => setTimeout(() => {
    resolve([{ orderId: 'A123', amount: 120.50 }, { orderId: 'B456', amount: 30.00 }]);
  }, 800));
};

const fetchNotifications = async () => {
  console.log('Fetching notifications...');
  return new Promise((_, reject) => setTimeout(() => {
    // Simula un errore nel recupero delle notifiche
    reject(new Error('Impossibile caricare le notifiche: server non risponde.'));
  }, 300));
};

const fetchPersonalizedNews = async () => {
  console.log('Fetching personalized news...');
  return new Promise(resolve => setTimeout(() => {
    resolve([{ title: 'Novità Tech', date: '2023-10-26' }]);
  }, 700));
};

async function loadDashboardData() {
  console.log('Avvio caricamento dashboard...');
  const start = Date.now();

  const results = await Promise.allSettled([
    fetchUserProfile(),
    fetchRecentOrders(),
    fetchNotifications(),
    fetchPersonalizedNews()
  ]);

  const end = Date.now();
  console.log(`Caricamento completato in ${end - start}ms.`);

  const dashboardData = {};

  results.forEach((result, index) => {
    switch (index) {
      case 0:
        if (result.status === 'fulfilled') {
          dashboardData.profile = result.value;
          console.log('Profilo utente caricato:', result.value);
        } else {
          dashboardData.profile = null;
          console.error('Errore caricamento profilo:', result.reason.message);
        }
        break;
      case 1:
        if (result.status === 'fulfilled') {
          dashboardData.orders = result.value;
          console.log('Ordini recenti caricati:', result.value);
        } else {
          dashboardData.orders = [];
          console.error('Errore caricamento ordini:', result.reason.message);
        }
        break;
      case 2:
        if (result.status === 'fulfilled') {
          dashboardData.notifications = result.value;
          console.log('Notifiche caricate:', result.value);
        } else {
          dashboardData.notifications = [];
          console.error('Errore caricamento notifiche:', result.reason.message);
        }
        break;
      case 3:
        if (result.status === 'fulfilled') {
          dashboardData.news = result.value;
          console.log('Notizie personalizzate caricate:', result.value);
        } else {
          dashboardData.news = [];
          console.error('Errore caricamento notizie:', result.reason.message);
        }
        break;
    }
  });

  console.log('\
Stato finale della Dashboard:');
  console.log(dashboardData);
  
  // Qui potresti aggiornare la UI con dashboardData
  // ad esempio, mostrare un messaggio di errore per le notifiche
  // ma comunque visualizzare il profilo, gli ordini e le notizie.
}

loadDashboardData();

In questo esempio, anche se fetchNotifications() fallisce, le altre chiamate API (fetchUserProfile, fetchRecentOrders, fetchPersonalizedNews) vengono comunque completate e i loro dati sono disponibili. La logica successiva può quindi decidere di mostrare un messaggio di errore solo per la sezione notifiche, mantenendo il resto della dashboard funzionale e informativa. Questo è un enorme vantaggio per l'usabilità e la resilienza dell'applicazione.

Quando Usare Promise.allSettled() vs Promise.all()

La scelta tra Promise.all() e Promise.allSettled() dipende interamente dai requisiti specifici del vostro caso d'uso. Non esiste una soluzione universalmente migliore; piuttosto, sono strumenti diversi per problemi diversi.

Usa Promise.all() quando:

  • Le operazioni sono interdipendenti: Il successo di tutte le Promise è cruciale per l'intera operazione. Se una fallisce, l'intera operazione non ha senso o non può procedere. Ad esempio, se devi creare un utente e poi assegnargli dei permessi, e l'assegnazione dei permessi dipende dal successo della creazione dell'utente, un fallimento in uno dei passaggi rende inutile il completamento dell'altro.
  • Vuoi un fallimento rapido (fail-fast): Se un singolo errore deve immediatamente interrompere il flusso e segnalare un problema generale. Questo può essere utile per validazioni o operazioni critiche dove la coerenza è paramount.
  • Vuoi che il risultato sia un array puro di valori: Promise.all() restituisce direttamente i valori, rendendo il codice leggermente più semplice se non ci sono errori.

Usa Promise.allSettled() quando:

  • Le operazioni sono indipendenti: Il fallimento di una Promise non dovrebbe impedire il completamento o l'elaborazione delle altre. Come nell'esempio della dashboard, ogni widget è indipendente dagli altri.
  • Devi conoscere lo stato di ogni singola Promise: Hai bisogno di sapere esattamente quali Promise sono state risolte e quali rifiutate, e di accedere ai valori di successo e alle ragioni di errore per ogni singola operazione.
  • Vuoi gestire i risultati parziali: Anche in presenza di errori, vuoi comunque utilizzare i dati delle operazioni riuscite.
  • Stai costruendo componenti resilienti: Per UI o servizi che devono rimanere parzialmente funzionali anche in presenza di errori di rete o API.

Breve Menzione di Altri Costrutti

Per completezza, è utile menzionare altri due metodi statici di Promise che offrono comportamenti specifici per la gestione della concorrenza:

  • Promise.race(): Restituisce una Promise che si risolve o si rifiuta non appena una delle Promise nell'iterabile si risolve o si rifiuta. È utile quando si vuole ottenere il risultato della prima Promise che si completa, ignorando le altre.
  • Promise.any() (ES2021): Restituisce una Promise che si risolve non appena una delle Promise nell'iterabile si risolve. Se tutte le Promise nell'iterabile vengono rifiutate, allora la Promise restituita viene rifiutata con un AggregateError contenente tutte le ragioni dei rifiuti. È utile quando basta che una delle operazioni abbia successo.

Considerazioni sulle Performance e Best Practices

L'uso di Promise.allSettled() (e Promise.all()) è un'ottima pratica per migliorare le performance percepita delle applicazioni web, eseguendo operazioni in parallelo. Tuttavia, ci sono alcune considerazioni e best practices da tenere a mente:

  1. Non esagerare con la concorrenza: Sebbene eseguire operazioni in parallelo sia utile, un numero eccessivo di richieste HTTP simultanee può sovraccaricare il browser, il server di origine o i server di terze parti. I browser hanno limiti sul numero di connessioni simultanee allo stesso dominio. Usare troppe chiamate concorrenti potrebbe portare a rallentamenti o rifiuti delle richieste. Valutate se un batching o una limitazione del numero di richieste attive sia appropriato per il vostro caso.
  2. Gestione degli errori specifica: Con Promise.allSettled(), avete l'opportunità di gestire gli errori con grande granularità. Non limitatevi a un console.error generico. Distinguete tra errori di rete, errori del server (codici HTTP 4xx, 5xx), errori di parsing, ecc. Ogni tipo di errore potrebbe richiedere una reazione diversa nell'interfaccia utente o nella logica applicativa.
  3. Feedback all'utente: Durante l'esecuzione di chiamate API concorrenti, è fondamentale fornire un feedback visivo all'utente (es. spinner di caricamento). Questo migliora l'esperienza utente, facendogli percepire che l'applicazione è attiva e non bloccata. Con Promise.allSettled(), potete decidere di aggiornare i singoli componenti della UI man mano che i loro dati arrivano, o mostrare un unico spinner fino al completamento di tutte le chiamate, per poi gestire i singoli successi/fallimenti.
  4. Idempotenza delle operazioni: Se le vostre chiamate API comportano modifiche di stato (es. POST, PUT, DELETE), assicuratevi che siano idempotenti se c'è la possibilità di retry o di chiamate duplicate accidentali. Questo è più una best practice generale per le API, ma diventa più rilevante quando si gestiscono scenari di fallimento e retry.
  5. Test approfonditi: Testate accuratamente il comportamento della vostra applicazione in scenari di successo parziale e fallimento totale. Simulate errori di rete, timeout e risposte del server con codici di errore per assicurarvi che la logica di gestione degli errori sia robusta.

Errori Comuni e Suggerimenti

L'uso di Promise.allSettled(), sebbene potente, può portare a errori se non si comprende appieno il suo funzionamento e la struttura dei suoi risultati.

  • Confondere il risultato di Promise.allSettled() con quello di Promise.all(): Questo è l'errore più comune. Promise.all() restituisce un array di valori. Promise.allSettled() restituisce un array di oggetti risultato, ciascuno con status, value o reason. Tentare di accedere direttamente a results[0] come se fosse un valore (come si farebbe con Promise.all()) porterà a errori o a dati non attesi.

    • Suggerimento: Ricorda sempre di controllare result.status e poi accedere a result.value o result.reason.
  • Non gestire la reason in caso di rifiuto: A volte, i developer si concentrano solo sui successi e ignorano completamente la reason degli errori. La reason è cruciale per il debug e per fornire un feedback significativo all'utente o per loggare l'errore in modo appropriato.

    • Suggerimento: Assicurati sempre di estrarre e utilizzare la reason.message o l'oggetto Error completo per la diagnostica.
  • Tentare di usare try/catch direttamente attorno a await Promise.allSettled(...) per catturare errori interni: Promise.allSettled() non rifiuta mai la sua Promise esterna. Si risolve sempre con l'array di risultati. Quindi, un try/catch esterno catturerà solo errori che si verificano prima che Promise.allSettled() venga chiamato o errori nella gestione successiva dei risultati, ma non gli errori delle Promise interne.

    • Suggerimento: Gestisci gli errori di ogni Promise interna controllando result.status === 'rejected' nell'array dei risultati, come mostrato negli esempi.
  • Dimenticare di gestire errori nelle singole Promise prima di passarle a Promise.allSettled(): Se le tue singole Promise non gestiscono alcun errore interno (es. un .catch() o una gestione esplicita), il reason nell'oggetto risultato di Promise.allSettled() potrebbe essere un oggetto Error generico o meno informativo. È buona pratica che le Promise individuali gestiscano i loro specifici tipi di errore (es. parsing JSON fallito, timeout) e lancino errori più significativi.

    • Suggerimento: Incapsula le tue chiamate API in funzioni che gestiscano errori specifici e che lancino errori ben definiti, in modo che la reason sia utile.

Prossimi Passi per Approfondire

La padronanza di Promise.allSettled() è un passo importante verso la creazione di applicazioni web più robuste e reattive. Per continuare il vostro percorso di apprendimento, considerate i seguenti argomenti:

  1. Approfondire async/await: Sebbene sia stato utilizzato in questo articolo, una comprensione più approfondita di come async/await si traduce in Promise e come gestisce il flusso di controllo può migliorare ulteriormente la vostra capacità di scrivere codice asincrono pulito e efficiente.
  2. Implementare un Meccanismo di Retry: In scenari reali, le chiamate API possono fallire temporaneamente. Implementare un meccanismo di retry con backoff esponenziale per le chiamate fallite può aumentare la resilienza della vostra applicazione. Questo può essere combinato efficacemente con i risultati di Promise.allSettled() per ritentare solo le operazioni che hanno fallito.
  3. Esplorare Librerie HTTP Client: Librerie come Axios o la stessa Fetch API con wrapper personalizzati offrono funzionalità avanzate come intercettori di richiesta/risposta, timeout configurabili e gestione automatica degli errori, che possono semplificare ulteriormente la gestione delle chiamate API.
  4. Gestione dello Stato con Framework: In applicazioni React, Vue o Angular, la gestione dello stato delle chiamate API e dei loro risultati (loading, success, error) è spesso integrata con librerie di gestione dello stato (es. Redux, Vuex, NGRX) o con hook specifici (useState, useEffect in React). Comprendere come Promise.allSettled() si integra in questi contesti è fondamentale.
  5. Ottimizzazione delle Risorse: Analizzate l'impatto delle chiamate concorrenti sulle risorse del browser e del server. Strumenti di sviluppo del browser possono aiutarvi a monitorare le richieste di rete, i tempi di caricamento e l'utilizzo della CPU per identificare eventuali colli di bottiglia.

Utilizzando Promise.allSettled() in modo strategico, potete costruire applicazioni web che non solo sono più veloci, ma anche significativamente più resilienti agli imprevisti, offrendo un'esperienza utente superiore anche quando non tutto va secondo i piani.