Aprende leyendo en orden

Manejo de errores y tiempos de espera en fetch — AbortController y reintentos

Errores HTTP que fetch no lanza, errores de red en los que falla el propio fetch, límites de tiempo con AbortController y setTimeout, y un solo reintento.

Si lees response.ok, sabes cuándo el servidor responde que un archivo no existe. Pero si no hay conexión, la línea de await fetch lanza una excepción antes de que llegues a ok. Y si la respuesta tarda, el código se queda esperando sin pasar a la siguiente línea.

En este artículo verás cómo recibir cada tipo de fallo de fetch como una excepción y cómo cancelar una solicitud con AbortController.

Convertir las respuestas fallidas en excepciones — throw new Error

Imagina que tres pantallas distintas cargan la lista de pedidos. Si loadJson devuelve null cuando falla, como en el artículo anterior, cada pantalla tiene que revisar si recibió null, y si una se olvida, la línea que lee el valor más adelante lanza un error.

Con un error HTTP (la solicitud llegó al servidor, pero volvió con un status como 403 o 404 que indica un fallo), fetch no lanza ninguna excepción. Si haces throw new Error(...) cuando ok es false, quien llama puede manejar el fallo en un solo lugar con try / catch.

// Si ok es false, lanza una excepción que incluye el status
async function loadJson(path) {
  const response = await fetch(path);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${path}`);
  }
  return await response.json();
}

try {
  const orders = await loadJson("fixtures/es/order.json");   // esta línea lanza la excepción
  console.log(`Pedidos: ${orders.length}`);                   // nunca se ejecuta
} catch (error) {
  // 403 en la consola de los ejercicios; 404 u otro código en otros servidores
  console.log(error.message);   // HTTP 403: fixtures/es/order.json
}
Con throw, la causa llega a catch
Si ok es false,devuelve nullordersqueda en nullLeeorders.lengthTypeError, y sepierde el 403Si ok es false,hace throwLa línea deawait loadJsonlanza el errorSalta el restoy pasa a catcherror.message esHTTP 403: …
Si devuelves null, obtienes otro error en la línea de orders.length. Con throw, un mensaje que incluye el status llega a catch.

Con throw, aunque una pantalla se olvide del catch, el error sin capturar sigue diciendo HTTP 403. En lugar de escribir en cada pantalla un if que revise si hay null, solo tienes que decidir dónde capturar el fallo.

En una pantalla de estado de envíos, carga la lista de pedidos. El esqueleto de la función loadOrders ya está escrito, y las dos rutas aparecen en los comentarios al principio del código.

① Si la respuesta indica un fallo, lanza una excepción cuyo mensaje sea «No se pueden cargar los pedidos: » seguido de la ruta.

② Si sale bien, convierte el cuerpo en un array y devuélvelo.

③ Carga con la ruta correcta y muestra el importe total de los pedidos.

④ Carga con la ruta mal escrita y, si falla, muestra el mensaje de la excepción.

(Si se ejecuta correctamente, aparecerá una explicación.)

Editor JavaScript / TypeScript

Ejecutar el código para ver el resultado

Distinguir los fallos de red — TypeError e instanceof

Cuando estás fuera y pierdes la señal, la solicitud nunca llega al servidor y no vuelve ninguna respuesta. Este fallo termina en el mismo catch que un error HTTP, así que, tal como está el código, mostrarías el mismo mensaje para «el archivo no existe» y para «no hay conexión».

Con un error de red (un fallo en el que no llega ninguna respuesta, por ejemplo porque se cortó la conexión o el dominio no existe), la Promise que devuelve fetch pasa a rejected con un TypeError. Si revisas instanceof TypeError en catch, puedes distinguirlo de un Error que lanzaste tú.

// loadJson es la misma de la sección anterior (lanza una excepción si ok es false)
const urls = [
  "fixtures/es/order.json",             // llega al servidor, pero el archivo no existe
  "https://shop.invalid/orders.json",   // un dominio .invalid nunca se conecta a ningún lado
];

for (const url of urls) {
  try {
    await loadJson(url);
  } catch (error) {
    // ¿Es un Error que lanzaste tú o un TypeError que lanzó fetch?
    const kind = error instanceof TypeError ? "Sin conexión" : "Falló la respuesta";
    console.log(`${kind} / ${error.name}: ${error.message}`);
  }
}
// Falló la respuesta / Error: HTTP 403: fixtures/es/order.json
// Sin conexión / TypeError: Failed to fetch
Dónde se origina cada tipo de fallo
El try de quien llama — la línea await loadJson(url)
  • Cualquiera de los dos fallos lanza una excepción en esta línea y pasa a catch
  • En catch, revisa error instanceof TypeError para distinguirlos
Dentro de loadJson
  • if (!response.ok) — con …/es/order.json llega una respuesta y ok es false
  • throw new Error(...) — un Error que lanzas tú
Dentro de await fetch(url)
  • https://shop.invalid/… — no llega ninguna respuesta
  • TypeError: Failed to fetchlo lanza fetch
loadJson lanza los errores HTTP y fetch lanza los errores de red. Los dos llegan al mismo catch, así que distínguelos por tipo.

Un TypeError del fetch interno sale de loadJson sin llegar a su if. Como cada pantalla quiere mostrar su propio mensaje, loadJson solo lanza la excepción, y el catch de quien llama, que recibe los dos tipos, revisa de cuál se trata.

El mensaje del error de red cambia según el navegador

El message del TypeError que lanza fetch depende del navegador: Failed to fetch en Chrome y Load failed en Safari. Si lo compruebas comparando el texto del mensaje, en otros navegadores terminarás en la rama equivocada. Revisa el tipo con instanceof TypeError.

En una pantalla de inventario, muestra un mensaje distinto para cada tipo de fallo. loadJson y urls (con tres URL) ya están declarados.

① En showStock, carga la lista de productos y muestra una línea como «Productos: 4».

② Si la solicitud no llegó al servidor, muestra «No hay conexión con el servidor».

③ Con cualquier otro fallo, muestra «No se puede cargar la lista de productos».

④ Pasa cada URL de urls a showStock, en orden.

Editor JavaScript / TypeScript

Ejecutar el código para ver el resultado

Cancelar una solicitud por tiempo — AbortController

Un servidor saturado puede tardar decenas de segundos en responder. fetch no tiene ninguna opción para fijar un tiempo máximo de espera, así que la línea de await fetch no avanza hasta que llega la respuesta, y la pantalla se queda cargando. El código de esta sección usa slowFetch, un sustituto de fetch que tarda 300ms en responder.

Si creas un AbortController (un objeto que permite cancelar un fetch en curso) y lo pasas como fetch(url, { signal: controller.signal }), al llamar a abort() la solicitud falla con un AbortError.

Para cancelar una llamada programada, pasa a clearTimeout el ID del temporizador (un número que identifica la llamada programada) que devuelve setTimeout.

// slowFetch es un sustituto de fetch definido en la consola del Ejercicio 3; tarda 300ms en responder y se usa igual que fetch
const controller = new AbortController();

// Programa abort() para dentro de 100ms y guarda el ID del temporizador
const timerId = setTimeout(() => controller.abort(), 100);

try {
  // Igual que con fetch, pasa signal en el segundo argumento
  const response = await slowFetch("fixtures/es/orders.json", { signal: controller.signal });
  console.log(response.ok);     // la respuesta tarda 300ms, así que esto nunca se ejecuta
} catch (error) {
  console.log(error.name);      // AbortError
} finally {
  clearTimeout(timerId);        // cancela la llamada programada por si la respuesta llega antes
}
Qué llega primero: el temporizador o la respuesta
Límite 100msrespuesta a 300msA los 100ms seejecuta abort()fetch fallacon AbortErrorcatch avisaque se acabóel tiempoLímite 3000msrespuesta a 300msA los 300msllega la respuestaclearTimeouten finallyabort() nuncase llama
Si la respuesta tarda más que el límite, abort() se ejecuta primero y la solicitud falla. Si la respuesta llega a tiempo, cancela el abort() programado.

Poner clearTimeout en finally hace que no quede ninguna llamada programada, tanto si la solicitud sale bien como si falla. Si abort() se ejecuta antes de terminar de leer el cuerpo, json() también falla con un AbortError, así que deja return await response.json() dentro del try y pasa a finally solo después de leer el cuerpo.

En una pantalla de detalles del plan, pon un límite de tiempo a la carga de la lista de miembros. slowFetch, que reemplaza a una API lenta, ya está declarada.

① Dentro de loadUsers, programa la cancelación de la solicitud para cuando se cumpla el límite de tiempo.

② Con slowFetch, obtén la lista de miembros de forma que se pueda cancelar y devuelve el array del cuerpo.

③ Cancela la llamada programada tanto si la solicitud sale bien como si falla.

④ Con un límite de 3000ms, muestra la cantidad de miembros; con uno de 100ms, muestra el nombre del error.

Editor JavaScript / TypeScript

Ejecutar el código para ver el resultado

Volver a intentarlo una vez — reintentos y return await

En un celular dentro de un tren en movimiento, la conexión puede cortarse un momento y la solicitud falla, pero si lo intentas de nuevo enseguida, muchas veces funciona. En lugar de pedirle al usuario que recargue cada vez, el código puede reintentar solo y mantener los datos en pantalla.

Para hacer un reintento (volver a enviar una solicitud fallida a la misma URL), llama otra vez a la función dentro de catch. Sin un límite de intentos, el código seguiría enviando solicitudes mientras durara el corte. Por eso, si el segundo intento también falla, el error pasa directamente a quien llama.

// loadJson es la misma de la primera sección. Reintenta una sola vez, y solo con fallos de red y tiempos agotados
async function loadWithRetry(path) {
  try {
    return await loadJson(path);
  } catch (error) {
    const retryable = error instanceof TypeError || error.name === "AbortError";
    if (!retryable) {
      throw error;                                   // si lo reenvías, vuelve el mismo 403 o 404
    }
    console.log(`Reintentando tras ${error.name}`);  // Reintentando tras TypeError
    return await loadJson(path);                     // un segundo fallo pasa directo a quien llama
  }
}

try {
  await loadWithRetry("https://shop.invalid/orders.json");
} catch (error) {
  console.log(`Falló dos veces: ${error.name}`);   // Falló dos veces: TypeError
}
Solo se reintenta una vez
Llama aloadWithRetry(path)1.er intentosale bien1.er intentoTypeError1.er intentoTypeErrorNo hay2.º intento2.º intentosale bien2.º intentotambién TypeErrorDevuelve el arraydel 1.er intentoAvisa delreintento ydevuelve el arraySin 3.er intento:lanza el error
Si el primer intento sale bien, no hay segunda llamada. Si el segundo intento también falla, no hay un tercero: la excepción pasa a quien llama.

Un error HTTP, como el de fixtures/es/order.json, se vuelve a lanzar en el primer catch, así que pasa a quien llama sin una segunda llamada. La tabla de abajo muestra cómo reconocer cada tipo de fallo en catch y si conviene reintentarlo.

FalloCómo detectarlo en catch¿Reintentar?
Error de rederror instanceof TypeErrorUna vez (la red puede volver)
Tiempo agotado (abort())error.name === "AbortError"Una vez (puede bajar la carga)
Error HTTP (403, 404)Cualquier otroNo (misma respuesta)

return sin await no pasa por catch

Si escribes try { return loadJson(path); } sin await, la función sale del try en cuanto devuelve la Promise. Aunque esa Promise pase a rejected después, catch no se ejecuta y el fallo pasa directo a quien llama. Escribe return await loadJson(path);.

Carga la lista de miembros desde una API lenta. slowFetch y loadWithin ya están declaradas.

① En loadWithRetry, carga con un límite de tiempo de limitMs y devuelve el resultado.

② Solo si se agota el tiempo, muestra «Tiempo agotado, reintentando» y vuelve a cargar con un límite de 3000ms.

③ Carga los miembros con loadWithRetry y muestra una línea como «Miembros: 4».

④ Carga también la ruta equivocada y muestra «Abandonado: » seguido del mensaje de la excepción.

Editor JavaScript / TypeScript

Ejecutar el código para ver el resultado
QUIZ

Verificación de conocimientos

Responde cada pregunta una a una.

Pregunta 1Si haces await fetch(url) con una URL cuyo dominio no existe, ¿qué error.name llega a catch?

Pregunta 2Programas abort() para dentro de 100ms y pasas signal a una solicitud que tarda 300ms en responder. ¿Qué pasa?

Pregunta 3¿Qué fallo vuelve a lanzar el loadWithRetry de este artículo sin reintentarlo?