Diseño para solicitudes fallidas en Angular: estados, interceptores y reintentos
Aprende cómo clasificar los fallos HTTP en Angular, limpiar el estado de carga, centralizar el manejo en interceptores, reintentar de forma segura y mostrar mensajes con los que los usuarios puedan actuar.
La mayoría de los componentes de Angular se escriben para el escenario ideal: 200 OK, los datos en una propiedad y la plantilla renderizada. En producción, las conexiones se interrumpen, las sesiones expiran, los servidores fallan y los usuarios hacen doble clic en los botones de envío. Los patrones que se describen a continuación permiten que una aplicación Angular detecte esos fallos, los explique al usuario, se recupere cuando sea seguro y proporcione a los desarrolladores suficiente información para solucionar la causa.
El costo de ignorar el callback de error
Una solicitud típica a un componente se suscribe, asigna la respuesta y no hace nada más.
this.http.get('/api/accounts').subscribe({
next: (response) => {
this.accounts = response;
}
});
Si el servidor responde con un 500, no ocurre nada visible. La página permanece vacía o un indicador de carga sigue girando, y la única señal es una entrada en la consola que el usuario nunca abre.
La mejora mínima es contar con un manejador de error que registre el fallo y almacene un mensaje legible para la plantilla.
this.http.get('/api/accounts').subscribe({
next: (response) => {
this.accounts = response;
},
error: (error) => {
console.error('Failed to load accounts', error);
this.errorMessage = 'Unable to load accounts. Please try again.';
}
});
Eso funciona para una sola pantalla. En decenas de pantallas, copiar el manejador en todas partes genera mensajes inconsistentes, casos límite olvidados y código duplicado; por eso son importantes los patrones reutilizables.
No todos los fallos significan lo mismo
El código de estado suele indicar quién puede solucionar el problema, y eso determina cómo reaccionar.
400 Bad Request: la entrada es incorrecta
El frontend envió datos inválidos.
400 Bad Request
Por lo general, el usuario puede solucionarlo, por ejemplo corrigiendo un campo del formulario.
401 Unauthorized: la sesión falta o ha expirado
Faltan las credenciales, la mayoría de las veces porque el token de acceso ha expirado.
401 Unauthorized
La reacción habitual es redirigir al usuario para que inicie sesión, o primero renovar el token.
403 Forbidden: autenticado pero no se permite la acción
El servidor conoce al usuario, pero rechaza la acción.
403 Forbidden
Se trata de una rutina con control de acceso basado en roles. Enviar al usuario a iniciar sesión no ayudaría; necesitan saber que carecen de permisos.
404 No encontrado: el recurso no existe
La entidad fue eliminada o la URL está desactualizada.
404 Not Found
500 Error interno del servidor: el backend falló
Ocurrió un problema en el servidor.
500 Internal Server Error
El usuario no puede solucionar esto; ofrezca un mensaje claro y una forma de intentarlo nuevamente más tarde.
Errores de red: la solicitud nunca llegó
A veces la solicitud ni siquiera llega al servidor.
Internet connection lost
Server unavailable
DNS failure
Request timeout
Angular reporta estos como un HttpErrorResponse con status 0, distinguiéndolos de las respuestas reales del servidor. Esta categoría determina todo lo que sigue: si repetir la acción, redirigir o qué informar al usuario.
Modela la solicitud como un conjunto de estados
Seguir solo el éxito es un error frecuente. Considera la solicitud como una pequeña máquina de estados: comienza cargando y termina con éxito o error.
Loading
↓
Success
or
Loading
↓
Error
En un componente, eso significa tener indicadores explícitos para el estado de carga y el mensaje de error, que se actualizan en cada rama.
loading = false;
errorMessage = '';
loadAccounts() {
this.loading = true;
this.errorMessage = '';
this.http.get('/api/accounts').subscribe({
next: (data) => {
this.accounts = data;
this.loading = false;
},
error: () => {
this.errorMessage =
'Unable to load accounts. Please try again.';
this.loading = false;
}
});
}
El detalle crucial es reiniciar loading en la función de callback de error; sin ello, cada fallo deja un spinner interminable. También es importante borrar errorMessage al inicio para evitar que un error antiguo permanezca junto a datos nuevos.
Deja que finalize() se encargue de limpiar la bandera de carga
Reiniciar la bandera en dos lugares aumenta las posibilidades de errores. RxJS finalize() proporciona un único lugar para realizar las tareas de limpieza.
import { finalize } from 'rxjs';
loadAccounts() {
this.loading = true;
this.http.get('/api/accounts')
.pipe(
finalize(() => {
this.loading = false;
})
)
.subscribe({
next: (data) => {
this.accounts = data;
},
error: () => {
this.errorMessage =
'Unable to load accounts.';
}
});
}
La función de callback finalize() se ejecuta cuando el observable finaliza o genera un error, así como cuando la suscripción se cancela prematuramente. Esto lo hace ideal para ocultar los indicadores de carga, y las líneas duplicadas de this.loading = false desaparecen.
Centralizar el comportamiento compartido en un interceptor HTTP
Una sesión vencida o una interrupción general debe manejarse de manera idéntica en todas las páginas; por lo tanto, implementarla en cada componente resulta repetitivo y frágil. Un interceptor HTTP ve todas las solicitudes de HttpClient, lo que lo convierte en el lugar adecuado para políticas aplicables a toda la aplicación. Este ejemplo utiliza el estilo funcional HttpInterceptorFn y realiza decisiones según el estado de la respuesta.
export const errorInterceptor: HttpInterceptorFn =
(req, next) => {
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
console.log('Session expired');
}
if (error.status === 403) {
console.log('Access denied');
}
if (error.status >= 500) {
console.log('Server error');
}
return throwError(() => error);
})
);
};
Las llamadas de registro son marcadores de posición para una redirección, un mensaje emergente o un evento de monitoreo. Lo más importante es que el interceptor termina con throwError(() => error), por lo que el error aún llega al componente que lo llamó. El interceptor se encarga de la parte transversal, el componente maneja lo que solo él comprende, y esa separación mantiene el código mantenible. Los interceptores funcionales se registran con provideHttpClient(withInterceptors([...])).
Traducir errores técnicos a mensajes comprensibles para el usuario
Los servidores backend no siempre devuelven texto amigable:
{
"message": "NullPointerException at AccountService.java:142"
}
Eso ayuda a los responsables del servicio, pero al mostrárselo al cliente genera confusión y revela detalles de implementación. En su lugar, muestre algo escrito específicamente para el usuario.
We couldn't load your accounts right now.
Please try again.
Una sesión vencida merece una descripción propia:
Your session has expired.
Please sign in again.
Lo mismo ocurre con un problema de permisos:
You don't have permission to access this resource.
Un buen mensaje responde a tres preguntas:
- ¿Qué salió mal?
- ¿El usuario necesita tomar acción?
- ¿Qué puede hacer a continuación?
Mapear los códigos de estado a mensajes en un único lugar mantiene la redacción consistente.
Intente nuevamente los fallos transitorios, pero solo los seguros
Algunos fallos son momentáneos, como un problema de red. Para esos casos, RxJS ofrece retry().
this.http.get('/api/accounts')
.pipe(
retry(2)
)
.subscribe({
next: (data) => {
this.accounts = data;
},
error: () => {
this.errorMessage =
'Unable to load accounts.';
}
});
retry(2) vuelve a suscribirse hasta dos veces más antes de que el error llegue al callback error. Dado que los observables de HttpClient son “fríos”, cada nueva suscripción envía una solicitud nueva.
Ese es también el peligro. Si una solicitud de pago tuvo éxito en el servidor pero se perdió su respuesta, un intento de reintentar podría cobrar al cliente dos veces a menos que el backend soporte la idempotencia. Tenga cuidado con los reintentos automáticos para:
- Pagos
- Pedidos
Las solicitudes GET de solo lectura son mucho más fáciles de entender. Tenga en cuenta también que un simple retry() vuelve a intentarlo ante cada error, incluidos los 400 o 401 que nunca tendrán éxito; por lo general, se desea intentar nuevamente solo ante errores de red y respuestas 5xx.
Las reintentos se realizan con un retraso
Un reintento inmediato suele dirigirse al mismo servidor sobrecargado. El formulario de configuración de retry() permite añadir una pausa.
import { retry } from 'rxjs';
this.http.get('/api/accounts')
.pipe(
retry({
count: 2,
delay: 1000
})
)
.subscribe();
La solicitud recibe dos oportunidades más, con un segundo de diferencia entre ellas. delay también acepta una función, lo que permite implementar un retroceso exponencial (cada espera es más larga que la anterior) y omitir los reintentos para errores que no pueden resolverse. Este retroceso evita que muchos clientes con problemas ataquen al mismo tiempo a un servidor que se está recuperando.
Trate las sesiones vencidas como un problema global
Un escenario clásico: un usuario abre la aplicación por la mañana y vuelve horas después. El token ha vencido, por lo que la siguiente solicitud falla.
GET /api/accounts
401 Unauthorized
El interceptor es el lugar adecuado para detectar esto:
if (error.status === 401) {
// Clear authentication state
// Redirect to login
}
Manejarlo en un solo lugar impide que cualquier componente vuelva a implementar la lógica de vencimiento de sesiones. Con tokens de actualización, el interceptor puede intentar renovarlo antes de redirigir; cuando varias solicitudes fallan al mismo tiempo, consulte por qué los 401 concurrentes cierran la sesión de los usuarios para conocer el enfoque de actualización única.
Ponga un botón de intento nuevamente en manos del usuario
A menudo, la herramienta de recuperación más efectiva es un simple botón que repite la solicitud.
<div *ngIf="errorMessage">
<p>{{ errorMessage }}</p>
<button (click)="loadAccounts()">
Try Again
</button>
</div>
Imagínese a un usuario cuya conexión se interrumpe durante unos segundos. Una vez vuelva a conectarse, no necesita volver a cargar la aplicación ni perder su estado; solo tiene que presionar:
Try Again
Dado que loadAccounts() restablece primero su estado, sirve tanto para la carga inicial como para cada intento de recuperación. Los templates de Angular 17+ pueden utilizar @if en lugar de *ngIf.
Recurrir a datos obsoletos cuando la actualidad lo permite
Si falla la actualización del panel de control, las cifras cargadas anteriormente junto con una notificación clara suelen ser mejores que una página vacía.
Showing your last available data.
We couldn't refresh the dashboard.
Un caché hace posible esto, y la notificación es esencial. Evite el patrón en el que la actualidad sea crítica: los saldos, precios de acciones, inventario y detalles de pago deben mostrar un error en lugar de un valor obsoleto.
Registre suficiente contexto para depurar el fallo
Ocultar detalles a los usuarios no significa descartarlos. Un registro de logs útil contiene:
Endpoint
HTTP status
Timestamp
Request identifier
User/session context where appropriate
Error category
Una versión mínima del interceptor:
console.error({
endpoint: req.url,
status: error.status,
message: error.message
});
En producción nadie observa la consola, por lo que se debe enviar la carga útil a un servicio de monitoreo de errores donde el identificador de la solicitud lo relacione con los logs del backend. Nunca se deben registrar tokens ni datos personales innecesarios.
El equilibrio a buscar es: el usuario recibe un mensaje útil, y el desarrollador obtiene suficientes detalles para encontrar la causa.
No oculte los fallos detrás de resultados vacíos
Un atajo tentador es ignorar el error y devolver un valor vacío:
catchError(() => {
return of([]);
})
Cambia silenciosamente el significado: un array vacío hace creer que el servidor devolvió cero registros. Un usuario que no ve transacciones podría concluir que no existen, lo cual es peor que un error. Las soluciones alternativas pueden ser correctas, pero de forma intencionada; de lo contrario, se debe propagar el error:
catchError((error) => {
return throwError(() => error);
})
Dividir las responsabilidades entre el manejo global y el a nivel de funcionalidad
El manejo global es adecuado para problemas que tienen el mismo significado en todas partes:
401 → Authentication handling
403 → Authorization handling
500 → Generic server error
Estos pertenecen a un interceptor. Ahora considere:
POST /api/transfer-money
Una transferencia fallida necesita una explicación específica y una declaración clara de si el dinero se movió, algo que un mensaje genérico no puede proporcionar. La funcionalidad debe recibir el error, por eso el interceptor lo vuelve a lanzar. El diseño robusto consiste en un manejo global para problemas comunes, sumado a un manejo específico de cada funcionalidad cuando el contexto empresarial es relevante.
Lista de verificación para rutas de fallo
El manejo de errores consiste en diseñar qué ocurre cuando algo falla. Al revisar una función, pregúntese:
- ¿El usuario entiende qué sucedió?
- ¿Puede recuperarse sin volver a cargar la página?
- ¿Existe una forma de intentarlo nuevamente?
- ¿El indicador de carga se detiene siempre?
- ¿Los detalles técnicos quedan fuera de la interfaz?
- ¿El fallo se registra en algún lugar donde los desarrolladores puedan verlo?
- ¿Qué sucede cuando el token expira a mitad de sesión?
- ¿Qué ocurre cuando se interrumpe la conexión a la red?
- ¿Qué pasa cuando el backend devuelve datos en una estructura inesperada?
Puntos clave
Los fallos son algo normal, por lo que hay que procurar que ocurran de manera controlada. En Angular esto se logra mediante:
- Estados explícitos de carga, éxito y error para cada solicitud
finalize() para que se ejecute la limpieza sin importar cómo finalice la solicitudcatchError() se utiliza para controlar los errores, no para ocultarlosUna aplicación fiable se define por su comportamiento cuando algo sale mal.
Lecturas relacionadas
- Diseñando pantallas React para estados de carga, vacío, error y reintentos — Aprenda a modelar cada estado en el que pueden encontrarse las pantallas y formularios de React, desde estructuras básicas y actualizaciones automáticas hasta resultados vacíos, errores útiles y protección contra envíos duplicados.
- Causas del store, determinar consecuencias: diseñando estado minimal en React — Aprenda a identificar el estado redundante en React, reemplazar cadenas de sincronización basadas en Effect y banderas booleanas por valores derivados y uniones de estado, y decidir dónde debe residir el estado.