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.
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>;}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);});