wandres.dev
ERRORBOUNDARY A FONDO · errores en async

Errores async de producción: sección, taxonomía y telemetría

La culminacion del nivel: industrializar el manejo de errores async con tres disciplinas. Un limite y un recurso por seccion para que el fallo de un widget no borre la pagina. Una taxonomia de errores tipados en el fetcher, porque fetch no rechaza ante un 404 ni un 500 sino que resuelve con res.ok en falso, de modo que hay que normalizar los fallos a mano y distinguir el fallo de red del error HTTP con su status. Y una telemetria que registra con contexto y filtra lo esperado de lo inesperado, sin alertar por cada 404 pero si por cada 5xx, con cancelacion via AbortController y un trato aparte para AbortError.

⏱ 18 min

Capturar un error async es el principio; convertirlo en una interfaz que se degrada con criterio es la meta. Este nivel de cierre industrializa tres disciplinas: aislar los fallos por sección para que uno no borre la página, clasificar los errores en una taxonomía tipada porque fetch miente sobre lo que es un fallo, y observar con una telemetría que distingue lo esperado de lo alarmante. Con las tres, tu aplicación deja de caerse entera: se degrada por partes, sabe qué reintentar y avisa solo cuando importa.

🎯 Al terminar esta lección sabrás
  • Aislar con un ErrorBoundary y un recurso por sección independiente.
  • Normalizar los fallos en el fetcher porque fetch no rechaza ante un 404 ni un 500.
  • Distinguir un fallo de red de un error HTTP y decidir la política por su status.
  • Registrar telemetría con contexto y filtrar lo esperado, cancelando con AbortController.

Un límite y un recurso por sección

La estrategia robusta no es un límite en la raíz sino muchos límites pequeños, uno por región de valor independiente, cada uno con su propio recurso. Si el feed falla, la barra lateral y el perfil siguen sirviendo. Un helper vuelve el patrón repetible: cada sección envuelve su carga en su Suspense y su ErrorBoundary, de modo que su fallo se contiene y no contamina a las hermanas.

function Seccion(props: { nombre: string; children: JSX.Element }) {
  return (
    <ErrorBoundary
      fallback={(err, reset) => <FalloSeccion nombre={props.nombre} err={err} onReset={reset} />}
    >
      <Suspense fallback={<Esqueleto />}>{props.children}</Suspense>
    </ErrorBoundary>
  );
}

<>
  <Seccion nombre="barra"><Barra /></Seccion>
  <Seccion nombre="feed"><Feed /></Seccion>
  <Seccion nombre="perfil"><Perfil /></Seccion>
</>;

fetch no falla en un 404: normaliza con una taxonomía

El error más caro de esta parte del stack es creer que fetch rechaza cuando el servidor responde mal. No lo hace: fetch solo rechaza su promesa ante un fallo de red —sin conexión, DNS caído, CORS—, lanzando un TypeError. Un 404 o un 500 son respuestas exitosas desde su punto de vista: la promesa resuelve con un Response cuyo res.ok vale false. Si no lo compruebas, tratarás una página de error como si fueran datos válidos.

La disciplina es normalizar todos los modos de fallo en una taxonomía tipada dentro del fetcher, para que el resto de la aplicación razone sobre clases de error y no sobre detalles de red.

export class HttpError extends Error {
  constructor(readonly status: number, readonly url: string) {
    super(`HTTP ${status} en ${url}`);
    this.name = "HttpError";
  }
}
export class NetworkError extends Error {
  constructor(readonly causa: unknown) {
    super("Fallo de red");
    this.name = "NetworkError";
  }
}

export async function pedir<T>(url: string, signal?: AbortSignal): Promise<T> {
  let res: Response;
  try {
    res = await fetch(url, { signal });
  } catch (e) {
    throw new NetworkError(e);              // fetch solo rechaza por fallo de red
  }
  if (!res.ok) throw new HttpError(res.status, url); // el 404 y el 500 no rechazan solos
  return res.json() as Promise<T>;
}
flowchart TD
FETCH[fetch resuelve o rechaza] --> NET{rechazo de red}
NET -->|si| NERR[lanza NetworkError reintentable]
NET -->|no| OK{res.ok}
OK -->|no| HERR[lanza HttpError con status]
OK -->|si| DATA[devuelve los datos]
HERR --> POL{status}
POL -->|404| NF[no encontrado, no reintentar]
POL -->|5xx| RT[fallo de servidor, reintentar]
style DATA fill:#a6e3a1,color:#11111b
style NERR fill:#f38ba8,color:#11111b
style NF fill:#f9e2af,color:#11111b

Política por tipo: qué reintentar y qué no

Con los fallos ya tipados, el fallback deja de mostrar un mensaje genérico y aplica una política según la clase y el status. Un 404 es definitivo: reintentar no traerá un recurso que no existe, así que ofreces navegación, no un botón de reintento. Un 5xx o un fallo de red son transitorios: ahí sí tiene sentido reintentar. Un 401 pide autenticación, no reintento.

function clasificar(err: unknown): "ausente" | "reintentable" | "auth" | "fatal" {
  if (err instanceof HttpError) {
    if (err.status === 404) return "ausente";
    if (err.status === 401 || err.status === 403) return "auth";
    if (err.status >= 500) return "reintentable";
  }
  if (err instanceof NetworkError) return "reintentable";
  return "fatal";
}

<ErrorBoundary
  fallback={(err, reset) => (
    <Switch fallback={<Fatal err={err} />}>
      <Match when={clasificar(err) === "ausente"}><NoEncontrado /></Match>
      <Match when={clasificar(err) === "auth"}><IrALogin /></Match>
      <Match when={clasificar(err) === "reintentable"}>
        <button onClick={() => { refetch(); reset(); }}>Reintentar</button>
      </Match>
    </Switch>
  )}
>
  <Vista datos={datos()} />
</ErrorBoundary>;

El mensaje que ve el usuario y la acción que puede tomar dejan de ser un accidente: se derivan de la naturaleza del fallo. Esta es la diferencia entre una interfaz que dice “algo salió mal” y una que dice “esa página no existe” o “el servidor tiene problemas, prueba de nuevo”.

Telemetría: contexto sí, ruido no

El registro para el equipo es otra responsabilidad, y la clave para que sea útil es filtrar lo esperado de lo inesperado. Un 404 es información —quizá un enlace roto—, no una emergencia; alertar por cada uno ahoga tu telemetría en ruido y adormece al equipo. Un 5xx o un fallo de red sí merecen atención. onError registra con contexto y gradúa el nivel según la clase.

function ConTelemetria(props: { area: string; children: JSX.Element }) {
  onError((err) => {
    if (err instanceof DOMException && err.name === "AbortError") return; // cancelado: no es fallo
    const esperado = err instanceof HttpError && err.status < 500;
    reportar({ area: props.area, err, nivel: esperado ? "info" : "error", cuando: Date.now() });
  });
  return <>{props.children}</>;
}

Falta cerrar una fuga silenciosa: las peticiones obsoletas. Cuando la fuente de un recurso cambia rápido, la carga anterior sigue en vuelo y, al resolver tarde, puede lanzar sobre un contexto que ya no interesa. La defensa es un AbortController cancelado en el onCleanup del propio fetcher, que corre cuando la fuente cambia o el owner muere. Su AbortError no es un fallo real —tú lo provocaste—, así que la telemetría lo descarta, como hace la guarda de arriba.

const [datos] = createResource(() => id(), async (id) => {
  const ctrl = new AbortController();
  onCleanup(() => ctrl.abort());          // cancela la peticion vieja al cambiar id
  return pedir<Dato>(`/api/x/${id}`, ctrl.signal);
});
🧱

Aislar por sección

Un limite y un recurso por region independiente: el fallo de uno no borra a los demas.

🏷️

Tipar el fallo

El fetcher normaliza red y HTTP en clases con status, porque fetch no rechaza ante un 404.

📡

Observar con criterio

Telemetria con contexto que grada el nivel y descarta lo cancelado y lo esperado.

⚠️
Reintentar un 404 es diseñar una espera inútil

La política de reintento debe leer la clase del error, nunca aplicarse a ciegas. Un backoff que reintenta un 404 hace esperar al usuario por un recurso que no va a aparecer, y un reintento de un 401 repite una petición que seguirá sin credenciales. Reintenta solo lo transitorio —5xx, red— y resuelve el resto con navegación o autenticación. Un error mal clasificado convierte una recuperación en una trampa.

Un error async es un valor de dominio, y la robustez es cómo lo modelas antes de mostrarlo

La lección que corona este nivel es que la fragilidad de las aplicaciones async casi nunca nace en cómo capturas el error sino en cómo lo modelas antes de capturarlo. El punto de origen del desastre es tratar fetch como si su promesa reflejara el éxito de la operación cuando solo refleja el éxito del transporte: un 404 y un 500 resuelven, no rechazan, y quien no comprueba res.ok acaba pintando una página de error como si fuera un objeto de datos, con un fallo que aflora tres capas más abajo, irreconocible. La cura es invertir el orden del trabajo: antes de pensar en límites, en reset o en telemetría, defines una taxonomía —fallo de red, error HTTP con su status, cancelación intencional— y haces que el fetcher normalice todo modo de fallo a esa taxonomía en su frontera, de modo que a partir de ahí toda la aplicación razone sobre clases de error, no sobre las mentiras del transporte. Con los errores convertidos en valores de dominio, las otras dos disciplinas se vuelven casi triviales y puramente declarativas. La política es un switch sobre la clase: el 404 navega, el 5xx reintenta con backoff, el 401 autentica, y el usuario recibe un mensaje verdadero y una acción posible en lugar de un genérico inútil. La observación es un filtro sobre la misma clase: lo esperado se registra como información, lo inesperado alerta, lo cancelado se descarta, y tu telemetría deja de ahogarse en ruido para señalar lo que de verdad arde. Y el aislamiento es la geometría que lo contiene todo: un límite y un recurso por unidad de valor, para que ninguna clase de error, por grave que sea, tenga permiso para apagar más que su propia sección. Cuando modelas el error como un ciudadano de primera clase de tu dominio —con su tipo, su política y su nivel de alarma— antes de decidir dónde lo atrapas, tus aplicaciones dejan de romperse de formas que no entiendes: fallan de maneras que ya nombraste, en sitios que ya acotaste, y te avisan con exactamente el volumen que merece cada caso.

⚔️ Industrializa la resiliencia
  1. Envuelve tres secciones, cada una con su recurso y su Seccion; haz fallar una y comprueba que las otras dos siguen vivas.
  2. Escribe el fetcher pedir que lance NetworkError en el catch del fetch y HttpError cuando res.ok sea falso; verifica que un 404 llega tipado y no como datos.
  3. Implementa clasificar y un fallback con Switch/Match que muestre navegación en el 404 y botón de reintento en el 5xx.
  4. Añade ConTelemetria que registre en info los 4xx y en error los 5xx y de red, descartando AbortError.
  5. Cancela con AbortController en el onCleanup del fetcher, cambia la fuente rápido y confirma que la petición vieja se aborta sin ensuciar la telemetría.