Avvolgi una chiamata fetch() in un try/catch, chiama un endpoint che risponde con un 404, e il blocco catch non si attiva mai. La promise si risolve. res.json() analizza qualunque corpo il server abbia restituito, spesso un payload di errore come {"error": "user not found"}, e il resto della funzione lo tratta come il record che aveva richiesto. Nessuna eccezione, nessun avviso in console. Il dato sbagliato continua a propagarsi finché qualcos’altro non va in errore per colpa sua.
| |
Quel catch scatta solo per una richiesta che non si completa mai. Tutto il resto richiede un controllo che devi scrivere tu.
Perché fetch() tratta 404 e 500 come successo?
fetch() viene rifiutata quando la richiesta stessa fallisce: la risoluzione DNS fallisce, la connessione si interrompe a metà trasferimento, una policy CORS blocca la risposta prima che arrivi al tuo codice, la rete dell’utente scompare. In ognuno di questi casi nessuna risposta del server ti ha mai raggiunto.
Un 404 o un 500 è un server che risponde. La connessione ha funzionato ed è arrivata una risposta con header e corpo. Lo scambio si è completato; si è completato con una brutta notizia. La specifica Fetch considera questo un fetch riuscito e lascia a te il compito di controllare il codice di stato. response.ok è true per qualsiasi stato nel range 200–299 e false per tutto il resto, incluse le risposte 4xx/5xx e i redirect che fetch non ha seguito.
Questo è il dettaglio che inganna chiunque arrivi da Axios, che per default rifiuta qualsiasi stato non-2xx. Nessuno dei due comportamenti è sbagliato. La Fetch API rifiuta solo per fallimenti a livello di trasporto, perché considera un 404 un evento normale del web, non un’eccezione. Ma se ti aspetti la semantica di Axios da fetch(), finisci per portare quel bug in produzione.
Come controlli response.ok e lanci un tuo errore?
Un if, messo prima di toccare il corpo:
| |
Questo basta per uno script. Con più di un punto di chiamata, ripetere queste righe ovunque è il modo più sicuro per saltarne una, prima o poi. Avvolgilo una volta sola:
| |
Ora ogni chiamante riceve un’eccezione vera su uno stato negativo, che porta con sé il codice di stato e il corpo della risposta del server.
Come leggi il corpo dell’errore quando non è JSON?
Un 500 dalla tua API probabilmente torna come JSON. Un 502 da un reverse proxy o una pagina di blocco da un WAF di solito torna come HTML. Chiama res.json() su quello e ottieni un SyntaxError (“Unexpected token ‘<’”) che non ha niente a che fare con il fallimento reale e ti fa cercare il problema nel posto sbagliato.
Il fetchJSON qui sopra evita questo leggendo il corpo dell’errore con res.text(), che non si preoccupa se il contenuto è JSON valido. Se vuoi l’oggetto analizzato quando è JSON, controlla prima il content type:
| |
Il corpo di una Response si può leggere solo una volta. Se ti serve il testo grezzo per il log e il JSON analizzato per il chiamante, chiama prima res.clone() e leggi una copia ciascuno. Leggere l’originale due volte lancia “body stream already read.”
Come distingui un errore di rete da un errore HTTP?
Una volta che fetchJSON lancia in entrambi i casi, un chiamante che vuole reagire diversamente deve distinguere quale dei due ha ricevuto:
| |
Un fallimento di rete emerge come un semplice TypeError senza codice di stato, perché non c’è mai stata una risposta da cui leggerne uno. Il messaggio varia per browser: “Failed to fetch” su Chrome, “NetworkError when attempting to fetch resource” su Firefox. Quindi distingui in base al tipo, non al messaggio.
C’è un caso che sembra un fallimento di rete ma non lo è. Se una richiesta cross-origin fallisce con quel TypeError mentre i log del server mostrano che è arrivata ed ha risposto 200, il browser ha bloccato la risposta prima che il tuo codice la vedesse. La correzione sta negli header CORS del server, non nella gestione degli errori del client; /it/posts/web/cos-e-cors-come-risolvere-errori-cors/ spiega cosa devono dire quegli header.
Come aggiungi un timeout a fetch()?
fetch() non ha timeout. Aspetta quanto la connessione resta aperta, quindi un backend che si blocca invece di restituire un errore tiene la richiesta in sospeso all’infinito. AbortSignal.timeout() la interrompe:
| |
Dopo 5 secondi la richiesta si interrompe e la promise viene rifiutata con un TimeoutError, distinto dall’AbortError che ottieni quando annulli una richiesta manualmente:
| |
AbortSignal.timeout() è diventato Baseline, cioè supportato su tutti i browser principali attuali, nel 2024. Per un browser o un runtime più vecchio, costruisci la stessa cosa con setTimeout e un AbortController:
| |
Questo fallback rifiuta con AbortError, non TimeoutError, quindi un controllo come quello sopra deve accettare entrambi i nomi.
Se il chiamante deve anche annullare per ragioni proprie (l’utente ha chiuso il dialog, una richiesta più recente ha sostituito questa), combina entrambi i segnali con AbortSignal.any([ownSignal, timeoutSignal]) invece di scegliere uno solo. AbortSignal.any() è più recente di timeout(), quindi verifica che i tuoi runtime target lo supportino.
fetch vs Axios: chi lancia un errore su un 404?
fetch() | Axios | |
|---|---|---|
| Risposta 4xx / 5xx | Si risolve; controlla response.ok | Viene rifiutata; finisce nel catch |
| Corpo della risposta | Chiama .json() / .text() tu stesso | Analizzato automaticamente in response.data |
| Timeout della richiesta | Nessuno incluso; usa AbortSignal.timeout() | Opzione di configurazione timeout |
| Fallimento di rete / CORS | Rifiutata con TypeError | Rifiutata con un AxiosError |
fetch() è integrata in ogni runtime moderno, quindi non c’è nulla da installare. Axios offre di serie il rifiuto su stato negativo e il parsing JSON automatico, al costo di una dipendenza. Se il codebase importa già Axios per gli interceptor o la cancellazione, usa i suoi default. Se non lo fa, il wrapper fetchJSON qui sopra ti dà lo stesso comportamento in circa venti righe.
Quali errori lasciano gli errori di fetch non gestiti?
- Controllare
response.okdopo aver chiamato.json(). A quel punto il corpo è già stato consumato, e su una pagina di errore HTML il parsing ha già lanciato un’eccezione. Controllaokprima, leggi il corpo dopo. - Chiamare una funzione asincrona da un event handler senza
awaito.catch().onClick={() => fetchJSON(url)}non intercetta mai il rejection: nessun blocco catch si attiva, e a seconda del runtime ottieni un avviso di unhandled rejection o niente. /it/posts/javascript/async-await-e-event-loop-javascript/ spiega dove finisce quella promise rifiutata. - Un
catchche logga e restituisceundefined.catch (err) { console.error(err); }ingoia l’errore. Il chiamante riceveundefineddove si aspettava dati e si schianta altrove, più lontano dalla causa reale. - Assumere che ogni corpo non-2xx sia JSON. Questa è la causa più comune di “il mio error handler ha lanciato un errore diverso”. Leggi con
.text()o controlla primacontent-type.
Dove dovrebbe vivere il controllo response.ok?
In un solo modulo. Di default fetch() resta in silenzio, quindi ogni chiamata senza un controllo esplicito dello stato riproduce il bug visto all’inizio di questo articolo. Scrivi fetchJSON e HttpError una volta, aggiungi la gestione di timeout e content-type viste sopra, e fanne l’unico canale attraverso cui la tua app parla con un server. Una regola di lint o un’abitudine di code review che segnala le chiamate fetch() dirette fuori da quel modulo mantiene questa disciplina nel tempo.