Qualcosa chiama JSON.parse, direttamente o tramite res.json(), e la console dice una di queste due cose:

SyntaxError: Unexpected end of JSON input
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

Quasi tutti le leggono come una lamentela sul proprio JSON e vanno a guardare il proprio JSON. Il JSON non è quasi mai il problema. Di solito non c’è JSON per niente, e il parser vi sta raccontando la prima cosa che non ha potuto usare.

Questi sono i messaggi che stampa Chrome. Firefox e Safari formulano i loro in modo diverso; le cause qui sotto sono le stesse, da qualunque browser arrivi la segnalazione.

Cosa vi dice davvero ciascun messaggio

Provato in una console del browser, viene fuori questo:

JSON.parse("")                       // Unexpected end of JSON input
JSON.parse("   ")                    // Unexpected end of JSON input
JSON.parse("undefined")              // "undefined" is not valid JSON
JSON.parse("<html>")                 // Unexpected token '<', "<html>" is not valid JSON
JSON.parse('{"a":1,')                // Expected double-quoted property name in JSON
                                     //   at position 7 (line 1 column 8)

In quella lista c’è una regola diagnostica che nessuno dice ad alta voce, ed è la cosa più utile di questa pagina:

  • Un messaggio con un numero di posizione significa che davvero avete del JSON, e che finisce presto o diventa non valido a metà strada. Qualcosa ha troncato un payload vero.
  • Un messaggio che cita un frammento e finisce con “is not valid JSON” significa che non è mai stato JSON. Vi hanno dato qualcosa di completamente diverso.
  • “Unexpected end of JSON input” significa che il parser è arrivato alla fine prima di avere un valore completo, che in pratica vuol dire corpo vuoto. Gli spazi contano come vuoto.

Sono tre bug diversi con tre correzioni diverse, e il testo dell’errore è l’unica cosa che li separa.

Un corpo vuoto, e i quattro modi di ottenerne uno

“Unexpected end of JSON input” è quello che produce JSON.parse(""), quindi la domanda è perché il corpo fosse vuoto.

Una risposta 204 No Content è la versione onesta: il server vi dice che non c’è niente, e chiamarci .json() sopra è il bug. Un 200 con il corpo vuoto è la stessa situazione senza l’avvertimento. Un corpo già consumato lo fa anche: i corpi di Response sono stream e si leggono una volta, quindi await res.text() seguito da await res.json() fallisce alla seconda chiamata con esattamente questo messaggio. E una richiesta interrotta può lasciarvi un corpo parziale o vuoto a seconda di dove è stata tagliata.

Una pagina web, che è il caso frequente

L’altro messaggio è quello che incontrerete per davvero, e significa che la vostra richiesta ha restituito HTML.

È così frequente per un dettaglio di fetch su cui tutti inciampano una volta: fetch non rifiuta su 4xx o 5xx. Un 404 è un fetch perfettamente riuscito per quanto riguarda la promise. Quindi se l’URL è sbagliato, o la sessione è scaduta e un proxy ha risposto con una pagina di accesso, o un web application firewall ha servito una verifica, o il server ha lanciato un 500 e il vostro framework ha reso la propria pagina di errore, res.json() riceve un documento che inizia con <!DOCTYPE html> e segnala il <.

Una pagina di accesso è la peggiore fra queste, perché di solito arriva come un 200. Nel codice di stato non c’è niente di sbagliato. Il corpo è semplicemente un modulo di login.

Il miglioramento che si ferma a un carattere dall’essere utile

Questi messaggi prima erano peggiori. L’issue 6551 di V8, “JSON.parse errors can be user-friendly for short strings”, è stato aperto nel 2017 e realizzato nel maggio 2022: prima, Unexpected token N in JSON at position 0, e dopo, "NaN" is not valid JSON. La forma nuova cita quello che ha ricevuto, che è un miglioramento vero e il motivo per cui l’espressione “in JSON at position 0” è in gran parte scomparsa dai browser mentre resta in ogni risultato di ricerca a riguardo.

Quella citazione ha un limite di lunghezza, e misurarlo vale un minuto perché non è documentato da nessuna parte. Fate il parse di una stringa di venti caratteri o meno e il messaggio cita l’intera stringa. Da ventuno in su cita i primi dieci caratteri e aggiunge dei puntini.

Ora contate i caratteri di <!DOCTYPE . Sono esattamente dieci.

Quindi per la causa più frequente di questo errore il nuovo frammento così utile è "<!DOCTYPE "... ogni volta. Una pagina 404, una pagina 500, un reindirizzamento al login e una verifica di firewall producono carattere per carattere lo stesso messaggio. Chiedere un 404 vero dal nostro sito, un documento HTML di 3.407 byte, produce precisamente quello e nient’altro.

Il messaggio sembra citarvi il server. Per il caso in cui è più probabile che siate, sta citando l’unica parte della risposta che ogni pagina HTML ha in comune.

Ed è per questo che la correzione non è mai nel parser

Controllate la risposta prima di farne il parse, e dite cosa avete trovato quando è sbagliata:

const res = await fetch(url);

if (!res.ok) {
  throw new Error(`${res.status} ${res.statusText} from ${res.url}`);
}

const type = res.headers.get("content-type") || "";
if (!type.includes("application/json")) {
  throw new Error(`expected JSON, got ${type || "no content-type"} from ${res.url}`);
}

return res.json();

Entrambi i controlli si guadagnano il posto. Il primo esiste perché fetch non lancia su uno stato cattivo. Il secondo esiste perché un 200 può comunque essere HTML, che è esattamente il caso della pagina di accesso, e perché trasforma un errore illeggibile del parser in una frase che nomina lo stato, il tipo e l’URL.

E la versione che arriva in un ticket è peggio

Tutto quello che precede presuppone che possiate vedere la risposta. La persona che segnala il problema non può.

Quello che arriva è “dice qualcosa sul JSON”, o al massimo una foto di una riga di console con scritto is not valid JSON. Quella riga non porta il codice di stato, né il content-type, né l’URL richiesto, né il corpo. Tutte e cinque le cause qui sopra producono lo stesso testo in console, e quattro delle cinque sono invisibili senza la risposta.

Il che fa di questo uno degli errori in cui la console da sola non basta e la scheda di rete è l’artefatto che conta: la richiesta, il suo stato, il suo content-type e le prime centinaia di byte di quello che è tornato. Aprire la scheda di rete è un minuto di lavoro per chi lo ha già fatto e un altro pomeriggio per chi non lo ha fatto.

Vale la pena separarlo dal vicino, perché la gente cerca uno avendo l’altro: TypeError: Failed to fetch è la richiesta che non arriva mai a compimento. Questo errore è la richiesta che si compie perfettamente e restituisce la cosa sbagliata.

Session Replay

Estensione Chrome gratuita. Un clic sulla pagina che non si comporta come dovrebbe acquisisce lo screenshot, la console e il network log, e restituisce un link da incollare nel ticket.

Scarica l'estensione

Cosa portarsi via

Il messaggio nomina JSON perché JSON.parse è il punto in cui il vostro programma si è fermato, non quello in cui è andato storto. Leggetelo come un’affermazione sulla risposta: vuota, o non JSON, o troncata. Poi andate a guardare la risposta, perché il parser vi ha già detto tutto quello che sa.