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.

1
2
3
4
5
6
7
8
9
async function getUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    const user = await res.json();
    return user; // su un 404 questo è { error: "user not found" }
  } catch (err) {
    console.error('Failed to load user:', err);
  }
}

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:

1
2
3
4
5
6
7
async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) {
    throw new Error(`GET /api/users/${id} failed with ${res.status}`);
  }
  return res.json();
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class HttpError extends Error {
  constructor(response, body) {
    super(`${response.status} ${response.statusText}`);
    this.name = 'HttpError';
    this.status = response.status;
    this.body = body;
  }
}

async function fetchJSON(url, options) {
  const res = await fetch(url, options);
  if (!res.ok) {
    const body = await res.text().catch(() => '');
    throw new HttpError(res, body);
  }
  return res.json();
}

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:

1
2
3
4
5
6
7
if (!res.ok) {
  const contentType = res.headers.get('content-type') ?? '';
  const body = contentType.includes('application/json')
    ? await res.json().catch(() => null)
    : await res.text();
  throw new HttpError(res, body);
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
try {
  const user = await fetchJSON(`/api/users/${id}`);
  render(user);
} catch (err) {
  if (err instanceof HttpError && err.status === 404) {
    showNotFound();
  } else {
    showConnectionError(); // fallimento DNS, connessione interrotta, blocco CORS
  }
}

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:

1
2
3
4
5
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
  const res = await fetch(url, { ...options, signal: AbortSignal.timeout(timeoutMs) });
  if (!res.ok) throw new HttpError(res, await res.text());
  return res.json();
}

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:

1
2
3
4
5
6
7
try {
  await fetchWithTimeout('/api/slow-report');
} catch (err) {
  if (err.name === 'TimeoutError') {
    // la richiesta era ancora in corso ai 5s
  }
}

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:

1
2
3
4
5
function timeoutSignal(ms) {
  const controller = new AbortController();
  setTimeout(() => controller.abort(), ms);
  return controller.signal;
}

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 / 5xxSi risolve; controlla response.okViene rifiutata; finisce nel catch
Corpo della rispostaChiama .json() / .text() tu stessoAnalizzato automaticamente in response.data
Timeout della richiestaNessuno incluso; usa AbortSignal.timeout()Opzione di configurazione timeout
Fallimento di rete / CORSRifiutata con TypeErrorRifiutata 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.ok dopo 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. Controlla ok prima, leggi il corpo dopo.
  • Chiamare una funzione asincrona da un event handler senza await o .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 catch che logga e restituisce undefined. catch (err) { console.error(err); } ingoia l’errore. Il chiamante riceve undefined dove 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 prima content-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.

Articoli correlati