
Alguma coisa chama JSON.parse, direto ou através de res.json(), e o console diz uma destas
duas coisas:
SyntaxError: Unexpected end of JSON input
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
Quase todo mundo lê isso como uma reclamação sobre o próprio JSON e vai olhar o próprio JSON. O JSON quase nunca é o problema. Normalmente não existe JSON nenhum, e o parser está contando a primeira coisa que ele não conseguiu usar.
Essas são as mensagens que o Chrome imprime. Firefox e Safari redigem as deles de outro jeito; as causas abaixo são as mesmas, de qualquer navegador que venha o relato.
O que cada mensagem de fato diz
Testado em um console de navegador, é isto que sai:
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)
Nessa lista há uma regra de diagnóstico que ninguém diz em voz alta, e é a coisa mais útil desta página:
- Uma mensagem com um número de posição quer dizer que você realmente tem JSON, e que ele termina cedo ou fica inválido no meio do caminho. Algo truncou uma carga de verdade.
- Uma mensagem que cita um trecho e termina em “is not valid JSON” quer dizer que nunca foi JSON. Entregaram para você outra coisa completamente diferente.
- “Unexpected end of JSON input” quer dizer que o parser chegou ao fim antes de ter um valor completo, o que na prática significa corpo vazio. Espaços contam como vazio.
São três bugs diferentes com três correções diferentes, e o texto do erro é a única coisa que os separa.
Um corpo vazio, e os quatro jeitos de conseguir um
“Unexpected end of JSON input” é o que JSON.parse("") produz, então a pergunta é por que o corpo
estava vazio.
Uma resposta 204 No Content é a versão honesta: o servidor diz que não há nada, e chamar
.json() nela é o bug. Um 200 com corpo vazio é a mesma situação sem o aviso. Um corpo já
consumido também faz isso: corpos de Response são streams e se leem uma vez, então
await res.text() seguido de await res.json() falha na segunda chamada com exatamente esta
mensagem. E uma requisição abortada pode deixar um corpo parcial ou vazio, dependendo de onde
foi cortada.
Uma página web, que é o caso comum
A outra mensagem é a que você vai encontrar de verdade, e significa que a sua requisição devolveu HTML.
Ela é tão comum por causa de um detalhe do fetch em que todo mundo tropeça uma vez: o fetch
não rejeita em 4xx nem 5xx. Um 404 é um fetch perfeitamente bem-sucedido do ponto de vista da
promise. Então, se a URL está errada, ou a sessão expirou e um proxy respondeu com uma página de
login, ou um firewall de aplicação web serviu um desafio, ou o servidor lançou um 500 e o seu
framework renderizou a própria página de erro, o res.json() recebe um documento que começa com
<!DOCTYPE html> e relata o <.
Uma página de login é a pior delas, porque costuma chegar como 200. No código de status não há nada errado. O corpo é só um formulário de entrada.
A melhoria que para a um caractere de ser útil
Essas mensagens já foram piores. A issue 6551 do V8, “JSON.parse errors can be user-friendly for
short strings”, foi aberta em 2017 e implementada em maio de 2022: antes,
Unexpected token N in JSON at position 0, e depois, "NaN" is not valid JSON. A forma nova cita
o que recebeu, o que é uma melhoria real e o motivo de a expressão “in JSON at position 0” ter
praticamente desaparecido dos navegadores enquanto continua em todo resultado de busca sobre ela.
Essa citação tem um limite de tamanho, e medi-lo vale um minuto porque não está documentado em lugar nenhum. Faça o parse de uma string de vinte caracteres ou menos e a mensagem cita a string inteira. A partir de vinte e um, ela cita os dez primeiros caracteres e acrescenta três pontos.
Agora conte os caracteres de <!DOCTYPE . São exatamente dez.
Então, para a causa mais comum desse erro, o novo trecho tão útil é "<!DOCTYPE "... todas as
vezes. Uma página 404, uma página 500, um redirecionamento de login e um desafio de firewall
produzem caractere por caractere a mesma mensagem. Buscar um 404 real do nosso próprio site, um
documento HTML de 3.407 bytes, produz precisamente isso e nada mais.
A mensagem parece estar citando o servidor para você. Para o caso em que você mais provavelmente está, ela está citando a única parte da resposta que toda página HTML tem em comum.
E é por isso que a correção nunca está no parser
Confira a resposta antes de parsear, e diga o que encontrou quando estiver errada:
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();
As duas verificações merecem o lugar. A primeira existe porque o fetch não lança em um status
ruim. A segunda existe porque um 200 ainda pode ser HTML, que é exatamente o caso da página de
login, e porque transforma um erro ilegível de parser em uma frase que nomeia o status, o tipo e a
URL.
E a versão disso que chega num chamado é pior
Tudo acima pressupõe que você consegue ver a resposta. A pessoa que relata o problema não consegue.
O que chega é “diz alguma coisa de JSON”, ou na melhor das hipóteses uma foto de uma linha de
console escrito is not valid JSON. Essa linha não carrega o código de status, nem o content-type,
nem a URL que foi buscada, nem o corpo. As cinco causas acima produzem o mesmo texto no console, e
quatro das cinco são invisíveis sem a resposta.
O que faz desse um dos erros em que o console sozinho não basta e a aba de rede é o artefato que importa: a requisição, o status dela, o content-type dela e as primeiras centenas de bytes do que voltou. Abrir a aba de rede é um minuto de trabalho para quem já fez e outra tarde inteira para quem não fez.
Vale separar do vizinho, porque as pessoas procuram um tendo o outro:
TypeError: Failed to fetch é a requisição que nunca se completa. Este erro é a
requisição que se completa perfeitamente e devolve a coisa errada.
Session Replay
Extensão gratuita para Chrome. Um clique na página que está com problema captura a tela, o console e o log de rede, e entrega um link para você colar no chamado.
O que levar disso
A mensagem nomeia JSON porque JSON.parse é onde o seu programa parou, não onde ele deu errado.
Leia como uma afirmação sobre a resposta: vazia, ou não JSON, ou truncada. E então vá olhar a
resposta, porque o parser já lhe disse tudo o que sabe.