Un error empieza su vida como algo muy poco interesante: una base de datos que no responde y un servidor que devuelve 500 con un cuerpo HTML que nadie pidió. Termina su vida como una frase en una pantalla, algo del tipo "no hemos podido guardar los cambios, inténtalo de nuevo". Entre esos dos puntos hay un viaje, y la calidad de una aplicación se nota bastante en cuántas manos toca ese error durante el trayecto.
En la mayoría de bases de código el viaje no existe. Lo que hay es un teletransporte: el componente que hizo la llamada recibe el objeto crudo del cliente HTTP, lo inspecciona ahí mismo, y decide en el sitio qué texto poner. Multiplicado por cuarenta pantallas, eso son cuarenta criterios distintos sobre qué es un error recuperable, cuarenta formas de escribir el mismo mensaje y cuarenta oportunidades de olvidarse del caso en que no hay red.
Este artículo va del viaje. Cuatro capas, cada una con una responsabilidad que las otras no tienen, y una regla que las ordena: cada capa traduce el error a un vocabulario más cercano al usuario y más lejano al transporte. El interceptor habla de códigos HTTP, el mapeo habla de dominio, la política habla de comportamiento, y la última capa habla de píxeles.
Como el patrón no es de ningún framework concreto, cada bloque de código va en TypeScript, Kotlin y Swift. Merece la pena mirar las tres versiones aunque solo se trabaje en una, porque las diferencias son informativas: hay una capa que en React se resuelve con una clase del propio framework y en las otras dos hay que construirla a mano.
1. El síntoma: el catch copiado cuarenta veces
Este es el código que aparece cuando no hay sistema. No es un ejemplo de paja, es literalmente lo que se escribe cuando la única herramienta disponible es el try/catch.
1function SaveProfileButton({ profile }: { profile: Profile }) {
2 const [error, setError] = useState<string | null>(null)
3
4 async function handleSave() {
5 try {
6 const response = await fetch("/api/profile", {
7 method: "PUT",
8 body: JSON.stringify(profile),
9 })
10
11 if (!response.ok) {
12 if (response.status === 401) {
13 window.location.href = "/login"
14 return
15 }
16 if (response.status === 422) {
17 const body = await response.json()
18 setError(body.message ?? "Datos inválidos")
19 return
20 }
21 setError("Algo ha ido mal")
22 return
23 }
24
25 toast.success("Perfil guardado")
26 } catch (e) {
27 // ¿Red caída? ¿JSON malformado? ¿Un bug nuestro? Aquí ya no se sabe.
28 setError("Algo ha ido mal")
29 }
30 }
31}Hay cuatro problemas y ninguno es de estilo.
El primero es que la vista conoce códigos HTTP. 401 y 422 son detalles del transporte, y están escritos en un archivo cuyo trabajo es pintar un botón. El día que el backend cambie 422 por 400, hay que buscar el número por todo el proyecto.
El segundo es que el catch final es un cajón de sastre donde caen cosas que no tienen nada que ver entre sí: que el usuario esté en un túnel, que el servidor devuelva HTML donde se esperaba JSON, y que haya un fallo en nuestro propio código porque profile no era lo que creíamos. Las tres acaban en el mismo mensaje, y las tres piden respuestas muy distintas. En Kotlin y en Swift el problema es incluso peor de lo que parece, porque en ese catch genérico también entra la cancelación de la corrutina o de la Task, y tragársela rompe la concurrencia estructurada.
El tercero es que la decisión de redirigir al login vive en la pantalla de editar perfil. Esa decisión hay que repetirla, idéntica, en cada llamada de la aplicación, y bastará con olvidarla una vez para que un usuario con la sesión caducada se quede mirando un "algo ha ido mal" eterno.
El cuarto es el más caro a largo plazo: no hay ningún sitio donde responder a la pregunta "¿qué hace la aplicación cuando el backend falla?". La respuesta está repartida en cuarenta archivos y es diferente en cada uno.
El código a buscar suele ser un statusCode o un code() dentro de un archivo de UI. Si una pantalla compara números HTTP, es que la traducción no ha ocurrido y la vista está haciendo el trabajo de tres capas.
2. Capa 1, el interceptor: normalizar todo lo que puede salir mal
La primera capa es la única que tiene derecho a saber que existe HTTP. Su trabajo no es decidir nada, es garantizar una forma de salida única. Después de esta capa, el resto del sistema no puede recibir sorpresas.
Y es importante entender cuántas sorpresas distintas hay. Una petición de red no falla de una manera, falla de al menos cinco:
- El servidor responde con un código de error. Hay respuesta, hay
status, y a veces hay un cuerpo útil. - El servidor responde algo que no es lo que dijo que iba a responder.
200 OKcon HTML de una página de mantenimiento, o JSON truncado. El decodificador lanza y elstatuses correcto. - No hay respuesta. El dispositivo está sin conexión, el DNS falla, el certificado no valida. No hay
statusque mirar. - La respuesta llega tarde. Nadie la ha cancelado, así que la espera sigue viva para siempre. Este es el fallo que más se olvida porque no lanza nada.
- Alguien la cancela. El usuario navega a otra pantalla y se aborta la petición en curso. Técnicamente es un error, pero no es un fallo y no debería llegar al usuario.
Un cliente que no distinga estos cinco casos está condenado a mentir en el mensaje. Este es el que sí los distingue, sin dependencias más allá del cliente HTTP de cada plataforma.
1export type HttpFailure =
2 | { kind: "http"; status: number; body: unknown }
3 | { kind: "malformed"; status: number; raw: string }
4 | { kind: "offline" }
5 | { kind: "timeout"; ms: number }
6 | { kind: "aborted" }
7
8export class HttpError extends Error {
9 constructor(readonly failure: HttpFailure, readonly requestId: string | null) {
10 super(`HTTP failure: ${failure.kind}`)
11 this.name = "HttpError"
12 }
13}
14
15const TIMEOUT_MS = 15_000
16
17export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
18 const timeout = new AbortController()
19 const timer = setTimeout(() => timeout.abort(), TIMEOUT_MS)
20
21 let response: Response
22 try {
23 response = await fetch(path, { ...init, signal: timeout.signal })
24 } catch (cause) {
25 clearTimeout(timer)
26
27 // El propio timeout aborta, así que hay que separarlo de una cancelación externa.
28 if (timeout.signal.aborted) throw new HttpError({ kind: "timeout", ms: TIMEOUT_MS }, null)
29 if (init.signal?.aborted) throw new HttpError({ kind: "aborted" }, null)
30 throw new HttpError({ kind: "offline" }, null)
31 }
32 clearTimeout(timer)
33
34 const requestId = response.headers.get("x-request-id")
35 const raw = await response.text()
36
37 let parsed: unknown = null
38 if (raw.length > 0) {
39 try {
40 parsed = JSON.parse(raw)
41 } catch {
42 // 200 con HTML dentro es un fallo, no un éxito. Aquí se corta.
43 throw new HttpError({ kind: "malformed", status: response.status, raw }, requestId)
44 }
45 }
46
47 if (!response.ok) {
48 throw new HttpError({ kind: "http", status: response.status, body: parsed }, requestId)
49 }
50
51 return parsed as T
52}Fíjate en lo que estas funciones no hacen. No deciden si hay que reintentar, no muestran nada, no redirigen, no loguean. Lo único que aportan es una promesa muy concreta al resto del sistema: si esto falla, falla con un HttpError y su tipo de fallo es uno de un conjunto cerrado y conocido. Ese es todo el valor de la capa, y es enorme, porque a partir de aquí desaparece el catch en el que no se sabe qué ha entrado.
Merece la pena comparar cómo cada plataforma trata la cancelación, porque es el caso que más se hace mal. En TypeScript hay que distinguir a mano el abort propio del timeout del que viene de fuera, mirando qué señal está abortada. En Kotlin la cancelación es una excepción que hay que relanzar tal cual, y hay una trampa fina: withTimeout lanza una TimeoutCancellationException, que es una CancellationException, así que el orden de los catch importa. En Swift llega como URLError.cancelled o como CancellationError según de dónde venga. Tres modelos distintos para el mismo concepto, y el interceptor es el sitio donde se aplanan.
Que el timeout viva en el interceptor y no en cada llamada es la mitad del beneficio. Una petición sin timeout no produce un error, produce un spinner infinito, y un spinner infinito no aparece en ninguna métrica de errores. Es el fallo más difícil de detectar en producción precisamente porque es silencioso.
3. Capa 2, del error de red al error de dominio
Aquí ocurre la traducción importante. La capa anterior habla de status y de tipos de fallo de transporte. La aplicación no quiere saber de eso: quiere saber si el usuario no tiene permiso, si los datos son inválidos, si el recurso ya no existe o si esto es algo transitorio que merece otro intento.
Esa lista es la taxonomía, y conviene que sea corta. Cinco o seis tipos bastan para casi cualquier aplicación, y la prueba de que un tipo sobra es que su comportamiento por defecto es idéntico al de otro.
1export class AuthError extends Error {} // La sesión no vale. Hay que renovarla o salir.
2export class ForbiddenError extends Error {} // La sesión vale, el permiso no. No se reintenta.
3export class ValidationError extends Error { // El usuario puede arreglarlo.
4 constructor(readonly fields: Record<string, string>, message: string) {
5 super(message)
6 }
7}
8export class NotFoundError extends Error {} // Existía o nunca existió. La vista decide.
9export class TransientError extends Error {} // Culpa nuestra o de la red, y puede pasar.
10export class UnknownError extends Error {} // No lo entendemos. Hay que investigarlo.Y el traductor. Es una función pura, aburrida y trivial de testear, que es exactamente lo que se quiere de la pieza que concentra todo el conocimiento sobre el contrato del backend.
1export function toDomainError(error: unknown): Error {
2 if (!(error instanceof HttpError)) {
3 // Un error que no es HttpError es un bug nuestro, no un fallo de red.
4 return new UnknownError(error instanceof Error ? error.message : "unknown")
5 }
6
7 const { failure } = error
8
9 switch (failure.kind) {
10 case "offline":
11 case "timeout":
12 case "malformed":
13 return new TransientError("La conexión ha fallado")
14
15 case "aborted":
16 return error // No es un fallo. Se propaga tal cual para que nadie lo muestre.
17
18 case "http": {
19 const message = extractMessage(failure.body)
20
21 if (failure.status === 401) return new AuthError(message)
22 if (failure.status === 403) return new ForbiddenError(message)
23 if (failure.status === 404) return new NotFoundError(message)
24 if (failure.status === 422) return new ValidationError(extractFields(failure.body), message)
25 if (failure.status === 429) return new TransientError(message)
26 if (failure.status >= 500) return new TransientError(message)
27
28 return new UnknownError(message)
29 }
30 }
31}El patrón es el mismo en los tres lenguajes, y eso no es casualidad: no es una técnica de React ni de TypeScript, es una frontera entre el transporte y el dominio. Donde sí hay diferencia es en cuánta ayuda da el compilador. En Kotlin y en Swift los tipos cerrados (sealed class y enum) hacen que el when o el switch sea exhaustivo: si mañana el interceptor añade un sexto tipo de fallo, el traductor deja de compilar y alguien tiene que decidir qué hacer con él. En TypeScript se consigue lo mismo con una unión discriminada, que es exactamente la razón por la que HttpFailure se declaró así arriba y no como una clase con campos opcionales.
Un detalle que se pasa por alto: el error transitorio agrupa cosas de origen muy distinto (un 500, un timeout, un 429, una respuesta corrupta) porque desde el punto de vista del usuario son la misma cosa: no ha funcionado y probablemente funcione dentro de un momento. La taxonomía se organiza por lo que el usuario debe hacer, no por lo que ha pasado técnicamente. Esa distinción es la que mantiene la lista corta.
Un tipo de error se justifica por el comportamiento distinto que provoca. Si dos tipos hacen exactamente lo mismo, no son dos tipos, son un tipo con dos causas.
4. Capa 3, la política: el comportamiento por defecto
Con la taxonomía en la mano se puede responder a la pregunta que antes estaba repartida en cuarenta archivos: qué hace la aplicación, por defecto, ante cada tipo de error. Y se puede responder en un solo sitio.
1type Policy = {
2 retries: number
3 notify: "toast" | "silent" | "screen"
4 onRaise?: () => void
5 rethrowToBoundary: boolean
6}
7
8export const POLICY: Record<string, Policy> = {
9 AuthError: { retries: 0, notify: "silent", onRaise: renewSessionOrLogout, rethrowToBoundary: false },
10 ForbiddenError: { retries: 0, notify: "screen", rethrowToBoundary: false },
11 ValidationError: { retries: 0, notify: "silent", rethrowToBoundary: false }, // La vista lo pinta.
12 NotFoundError: { retries: 0, notify: "screen", rethrowToBoundary: false },
13 TransientError: { retries: 2, notify: "toast", rethrowToBoundary: false },
14 UnknownError: { retries: 0, notify: "silent", rethrowToBoundary: true }, // Que rompa y se vea.
15}Esas seis l íneas son documentación ejecutable. Un desarrollador nuevo las lee y sabe cómo se comporta el producto entero ante fallos, y un cambio de criterio ("los 429 van a reintentar tres veces con espera creciente") se hace una vez.
La decisión menos obvia y la más importante es la última fila. Un error desconocido significa que el backend ha devuelto algo que no sabemos interpretar o que tenemos un bug. La tentación es mostrar un mensaje amable y seguir, pero eso convierte un fallo desconocido en un fallo invisible, y un fallo invisible nunca se arregla. Lo correcto es dejar que rompa de forma controlada y visible.
Y la política se aplica en la capa de acceso a datos, no en la UI:
1export async function call<T>(path: string, init?: RequestInit): Promise<T> {
2 let attempt = 0
3
4 while (true) {
5 try {
6 return await request<T>(path, init)
7 } catch (raw) {
8 const error = toDomainError(raw)
9 const policy = POLICY[error.constructor.name]
10
11 if (error instanceof HttpError) throw error // Cancelación: fuera sin ruido.
12
13 if (policy && attempt < policy.retries) {
14 attempt++
15 await wait(2 ** attempt * 250) // Espera exponencial: 500ms, 1s.
16 continue
17 }
18
19 policy?.onRaise?.()
20 if (policy?.notify === "toast") toast.error(userMessage(error))
21
22 throw error
23 }
24 }
25}Nótese que call sigue lanzando siempre. La política decide los efectos secundarios (reintentar, avisar, renovar sesión), pero no se come el error: quien llamó tiene derecho a saber que no hay datos. Una capa de política que devuelve null en silencio es peor que no tener capa, porque produce pantallas vacías sin explicación.
Reintentar solo es seguro si la operación es idempotente. Un GET se puede repetir tres veces sin consecuencias; un POST que crea un pago, no. En cuanto haya escrituras, el reintento automático necesita una clave de idempotencia enviada por el cliente, para que el servidor reconozca el segundo intento como el mismo del primero y no cobre dos veces.
5. Capa 4, la red de seguridad de render
Las tres capas anteriores cubren los errores que se esperaban. La cuarta cubre los que no: el valor nulo al que se le pide una propiedad, el bucle sobre algo que no era una lista, el error desconocido que la política decidió dejar volar. Sin esta capa, cualquiera de ellos deja la pantalla en blanco o tira la aplicación entera.
Y aquí es donde las tres plataformas dejan de parecerse. React tiene un mecanismo de primera clase para esto, el ErrorBoundary. Compose y SwiftUI no tienen nada parecido: una excepción durante la composición o el body de una vista no se puede interceptar por subárbol, así que la red de seguridad hay que construirla a mano, y consiste en no dejar que el error llegue nunca al render.
1type Props = { fallback: (error: Error, reset: () => void) => ReactNode; children: ReactNode }
2type State = { error: Error | null }
3
4export class ErrorBoundary extends Component<Props, State> {
5 state: State = { error: null }
6
7 static getDerivedStateFromError(error: Error): State {
8 return { error }
9 }
10
11 componentDidCatch(error: Error, info: ErrorInfo) {
12 reportToMonitoring(error, { componentStack: info.componentStack })
13 }
14
15 render() {
16 if (this.state.error) {
17 return this.props.fallback(this.state.error, () => this.setState({ error: null }))
18 }
19 return this.props.children
20 }
21}Lo que hay que tener claro del ErrorBoundary de React es su límite: solo captura errores lanzados durante el render, en los métodos de ciclo de vida y en los constructores de sus hijos. No captura nada de un handler de eventos, ni de un setTimeout, ni de una promesa rechazada. Ese es el motivo por el que las capas 1 a 3 existen: el error asíncrono no llega al boundary por sí solo, hay que reflejarlo primero en el estado del componente para que el siguiente render lo lance. En Compose y en SwiftUI ese "reflejarlo en el estado" no es un truco, es la única opción, y por eso el UiState con una rama de fallo explícita acaba siendo el patrón dominante en las dos.
El otro error habitual es poner uno solo, en la raíz. Con esa configuración, un fallo al cargar la lista de "productos recomendados" tira la aplicación completa, incluido el carrito. La granularidad correcta son tres niveles, y esto aplica igual en cualquier árbol de UI:
1<ErrorBoundary fallback={fullPageCrash}> {/* Raíz: el último recurso. */}
2 <Layout>
3 <ErrorBoundary fallback={routeCrash} key={pathname}> {/* Ruta: se resetea al navegar. */}
4 <Dashboard>
5 <ErrorBoundary fallback={widgetCrash}> {/* Widget: degradación local. */}
6 <Recommendations />
7 </ErrorBoundary>
8 </Dashboard>
9 </ErrorBoundary>
10 </Layout>
11</ErrorBoundary>La key={pathname} en el boundary de ruta es un detalle pequeño con efecto grande: sin ella, el boundary se queda en estado de error para siempre y el usuario que navega a otra sección sigue viendo la pantalla roja de la anterior. Al cambiar la key, React desmonta el subárbol y el estado de error se va con él. En Compose el equivalente es asociar el estado a la ruta actual, con una key en el NavHost, para que al navegar se cree uno nuevo en lugar de reutilizar el que está en fallo.
Y con las cuatro capas puestas, el código del principio queda así:
1function SaveProfileButton({ profile }: { profile: Profile }) {
2 const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
3
4 async function handleSave() {
5 try {
6 await call("/api/profile", { method: "PUT", body: JSON.stringify(profile) })
7 toast.success("Perfil guardado")
8 } catch (error) {
9 // Solo se maneja el caso que de verdad pertenece a esta vista.
10 if (error instanceof ValidationError) setFieldErrors(error.fields)
11 }
12 }
13}No hay códigos HTTP, no hay redirección al login, no hay mensaje genérico, no hay reintentos. Todo eso sigue ocurriendo, pero ocurre en la capa que le corresponde, una sola vez, para toda la aplicación. Y fíjate en el catch DomainError.validation de Swift: el propio catch ya filtra por tipo, así que el resto de errores pasan de largo sin necesidad de un if que los descarte.
6. El error que sí pertenece a la vista
Hasta aquí puede parecer que el objetivo es que ninguna vista toque errores nunca. No es eso. El objetivo es que las vistas solo toquen los errores cuya respuesta depende de la vista concreta, y hay dos familias que cumplen esa condición.
La primera es la validación. Un 422 que dice que el campo email ya está en uso solo tiene sentido junto al campo email de ese formulario. Ninguna capa genérica sabe dónde está ese campo ni cómo resaltarlo, así que el error de validación se marca como silencioso en la política y viaja intacto hasta quien sabe qué hacer con él.
La segunda es el 404 con significado de producto. Un error de "no encontrado" al abrir el detalle de un pedido debe llevar a una pantalla de "este pedido no existe" con un enlace al listado. El mismo error al comprobar si un usuario tiene un borrador guardado no es un error en absoluto: significa "no hay borrador", y la respuesta correcta es un formulario vacío. Mismo tipo de error, dos comportamientos opuestos, y ninguna tabla central puede decidir entre ellos porque la información necesaria es el contexto de la llamada.
De ahí sale la regla para saber si un error debe subir a la política o quedarse en la vista: si la respuesta correcta cambia según qué pantalla hizo la llamada, es de la vista; si es siempre la misma, es de la política. Un 401 es siempre lo mismo en toda la aplicación, así que va arriba. Un 404 depende, así que se deja pasar.
7. Lo que se loguea y el hilo que une los dos lados
Un sistema de errores que no informa al equipo solo está siendo educado con el usuario. La cuarta responsabilidad, transversal a las cuatro capas, es la observabilidad, y aquí el criterio es al revés de lo que se suele hacer: loguear menos, no más.
Un error de validación no es un incidente, es el sistema funcionando (alguien escribió un email mal). Un error de sesión caducada tampoco, las sesiones caducan. Un error transitorio aislado es ruido de red normal. Si todo eso entra en la herramienta de monitorización, en dos semanas nadie mira las alertas y el error desconocido que sí importaba queda enterrado entre miles de eventos irrelevantes.
1const SEVERITY: Record<string, "ignore" | "breadcrumb" | "error"> = {
2 ValidationError: "ignore", // Uso normal, no es un incidente.
3 AuthError: "breadcrumb", // Contexto para el siguiente error de verdad.
4 ForbiddenError: "breadcrumb",
5 NotFoundError: "breadcrumb",
6 TransientError: "breadcrumb",
7 UnknownError: "error", // Lo único que despierta a alguien.
8}Los breadcrumb no alertan, pero quedan grabados y aparecen adjuntos al siguiente error de verdad. Cuando llega un error desconocido, el informe no dice solo "falló"; dice "falló después de tres errores transitorios y una sesión caducada en los últimos veinte segundos", que ya es media investigación resuelta.
La otra pieza es el x-request-id que el interceptor guardaba en la capa 1 y que ha viajado con el error hasta aquí. Ese identificador lo genera el backend (o lo genera el cliente y el backend lo respeta), y aparece en los logs de los dos lados. Sin él, investigar un fallo reportado por un usuario consiste en buscar por hora aproximada entre millones de líneas. Con él, es una consulta exacta que reconstruye la petición completa, y por eso merece la pena arrastrarlo desde el primer header hasta el último informe.
Pon el requestId en la propia pantalla de error, en letra pequeña. Cuesta cinco minutos y convierte "no me funciona" en un dato accionable en cuanto el usuario hace una captura.
8. El viaje completo
Vale la pena recorrer una vez el camino entero, porque el reparto de responsabilidades se entiende mejor de seguido que en capas aisladas.
La base de datos se cae. El servidor devuelve 503. La capa 1 ve que la respuesta no es correcta, extrae el x-request-id, y lanza un HttpError con un fallo de tipo HTTP y status: 503. Fin de su trabajo: no sabe qué significa 503 y no le importa. La capa 2 traduce ese 503 a un error transitorio, y con eso el número desaparece del sistema para siempre. La capa 3 consulta la política, ve dos reintentos, espera 500 milisegundos y reintenta; la base de datos sigue caída, espera un segundo, reintenta otra vez, y al fallar el tercer intento deja un breadcrumb, dispara un aviso con el mensaje humano y lanza el error hacia arriba. La capa 4 no llega a intervenir, porque el aviso ya informó y la pantalla decidió mostrar su estado vacío.
Cuatro traducciones, cada una en su sitio, y cero líneas de código de manejo de errores en la pantalla donde el usuario estaba trabajando. El mensaje que ve no lo escribió quien pintó el botón: lo escribió la capa que sabía lo que había pasado.
Ese es todo el truco, y es idéntico en las tres plataformas porque no depende de ninguna. El error no viaja solo, y en cada parada alguien le quita un detalle técnico y le añade un poco de intención.


