Envuelve una llamada fetch() en un try/catch, apunta a un endpoint que responde con un 404, y el bloque catch nunca se ejecuta. La promesa se resuelve. res.json() analiza el cuerpo que haya devuelto el servidor, muchas veces un payload de error como {"error": "user not found"}, y el resto de la función lo trata como el registro que pedía. Sin excepción, sin aviso en consola. El dato incorrecto sigue viajando hasta que algo más se rompe con él.
| |
Ese catch solo se activa para una petición que nunca llega a completarse. Todo lo demás necesita una comprobación que tienes que escribir tú.
¿Por qué fetch() trata un 404 y un 500 como éxito?
fetch() se rechaza cuando la petición en sí falla: la resolución DNS falla, la conexión se corta a mitad de la transferencia, una política CORS bloquea la respuesta antes de que llegue a tu código, la red del usuario desaparece. En cada uno de esos casos, ninguna respuesta del servidor llegó nunca hasta ti.
Un 404 o un 500 es un servidor respondiendo. La conexión funcionó y volvió una respuesta con cabeceras y cuerpo. El intercambio se completó; se completó con malas noticias. La especificación Fetch considera eso un fetch exitoso y deja el código de estado para que lo revises tú. response.ok es true para cualquier estado en el rango 200–299 y false para todo lo demás, incluidas las respuestas 4xx/5xx y las redirecciones que fetch no siguió.
Este es el detalle que confunde a cualquiera que venga de Axios, que por defecto rechaza cualquier estado que no sea 2xx. Ningún comportamiento es incorrecto en sí mismo. La Fetch API solo rechaza ante fallos de transporte: para ella, un 404 no es algo excepcional en la web. Pero si esperas que fetch() se comporte como Axios, acabas publicando el bug de arriba.
¿Cómo comprobar response.ok y lanzar tu propio error?
Basta un if, colocado antes de tocar el cuerpo:
| |
Eso basta para un script. Con más de un punto de llamada, repetir estas líneas en todas partes es la manera segura de que, tarde o temprano, se te olvide alguna. Envuélvelo una sola vez:
| |
Ahora cada llamador recibe una excepción real ante un estado incorrecto, con el código de estado y el cuerpo de respuesta del servidor adjuntos.
¿Cómo leer el cuerpo del error cuando no es JSON?
Un 500 de tu propia API probablemente llega como JSON. Un 502 de un reverse proxy o una página de bloqueo de un WAF suele llegar como HTML. Llamar a res.json() sobre eso te da un SyntaxError (“Unexpected token ‘<’”) que no tiene nada que ver con el fallo real, y te hace perder tiempo depurando el problema equivocado.
El fetchJSON de arriba evita esto leyendo el cuerpo del error con res.text(), que no le importa si el contenido es JSON válido. Si quieres el objeto analizado cuando sí es JSON, comprueba primero el content type:
| |
El cuerpo de una Response solo se puede leer una vez. Si necesitas el texto en crudo para el log y el JSON analizado para el llamador, llama primero a res.clone() y lee una copia cada uno. Leer el original dos veces lanza “body stream already read.”
¿Cómo distinguir un fallo de red de un error HTTP?
Una vez que fetchJSON lanza en ambos casos, un llamador que quiera reaccionar de forma distinta tiene que comprobar cuál de los dos errores recibió:
| |
Un fallo de red aparece como un simple TypeError sin código de estado, porque nunca hubo una respuesta de la que sacar uno. El mensaje varía según el navegador: “Failed to fetch” en Chrome, “NetworkError when attempting to fetch resource” en Firefox. Así que distingue por el tipo, no por el mensaje.
Hay un caso que parece un fallo de red pero no lo es. Si una petición cross-origin falla con ese TypeError mientras los logs del servidor muestran que llegó y respondió 200, el navegador bloqueó la respuesta antes de que tu código la viera. El arreglo está en las cabeceras CORS del servidor, no en el manejo de errores del cliente. Qué es CORS y cómo arreglar los errores CORS explica qué deben decir esas cabeceras.
¿Cómo añadir un timeout a fetch()?
fetch() no tiene timeout. Espera mientras la conexión siga abierta, así que un backend que se cuelga en vez de devolver un error deja la petición pendiente indefinidamente. AbortSignal.timeout() la corta:
| |
Tras 5 segundos la petición se cancela y la promesa se rechaza con un TimeoutError, distinto del AbortError que obtienes al cancelar una petición manualmente:
| |
AbortSignal.timeout() alcanzó la disponibilidad Baseline (soporte en todos los navegadores principales actuales) en 2024. Para un navegador o runtime más antiguo, construye lo mismo con setTimeout y un AbortController:
| |
Este fallback rechaza con AbortError, no TimeoutError, así que una comprobación como la de arriba tiene que aceptar ambos nombres.
Si el llamador también necesita cancelar por sus propias razones (el usuario cerró el diálogo, una petición más reciente sustituyó a esta), combina ambas señales con AbortSignal.any([ownSignal, timeoutSignal]) en vez de elegir una sola. AbortSignal.any() es más reciente que timeout(), así que confirma que tus runtimes objetivo lo soportan.
fetch vs Axios: ¿quién lanza error en un 404?
fetch() | Axios | |
|---|---|---|
| Respuesta 4xx / 5xx | Se resuelve; comprueba response.ok | Se rechaza; cae en el catch |
| Cuerpo de la respuesta | Llama a .json() / .text() tú mismo | Se analiza automáticamente en response.data |
| Timeout de la petición | Ninguno incluido; usa AbortSignal.timeout() | Opción de configuración timeout |
| Fallo de red / CORS | Se rechaza con TypeError | Se rechaza con un AxiosError |
fetch() viene integrada en cada runtime moderno, así que no hay nada que instalar. Axios lanza un error ante un estado incorrecto y analiza el JSON automáticamente, de serie, al coste de una dependencia. Si el código ya usa Axios para interceptores o cancelación, usa sus valores por defecto. Si no, el wrapper fetchJSON de arriba te da el mismo comportamiento en unas veinte líneas.
¿Qué errores dejan los fallos de fetch sin gestionar?
- Comprobar
response.okdespués de llamar a.json(). Para entonces el cuerpo ya se consumió, y en una página de error HTML el parseo ya lanzó una excepción. Compruebaokprimero, lee el cuerpo después. - Llamar a una función asíncrona desde un event handler sin
awaitni.catch().onClick={() => fetchJSON(url)}pierde el rechazo: ningún bloque catch se ejecuta, y según el runtime obtienes un aviso de unhandled rejection o nada. Async/Await y el Event Loop de JavaScript Explicados explica a dónde va esa promesa rechazada. - Un
catchque loguea y devuelveundefined.catch (err) { console.error(err); }se traga el error. El llamador recibeundefineddonde esperaba datos y falla en otro sitio, más lejos de la causa real. - Asumir que todo cuerpo no-2xx es JSON. Esta es la causa habitual de “mi manejador de errores lanzó un error distinto”. Lee con
.text()o comprueba primero elcontent-type.
¿Dónde debería vivir la comprobación de response.ok?
En un solo módulo. fetch() hace que el silencio sea el comportamiento por defecto, así que toda llamada sin una comprobación explícita del estado reproduce el bug del principio de este artículo. Escribe fetchJSON y HttpError una sola vez, añade el manejo de timeout y content-type de arriba, y hazlos la única forma en que tu app habla con un servidor. Una regla de lint o un hábito de revisión de código que señale las llamadas fetch() sueltas fuera de ese módulo mantiene esa disciplina.