Módulo 12: HttpClient
Objetivos del módulo
- Explicar qué aporta
HttpClientfrente a llamar afetchdirectamente - Configurar el cliente con
provideHttpClienty saber cuándo es necesario - Distinguir los dos backends,
fetchy XHR, y elegir conwithXhrcuando haga falta - Tipar las respuestas y entender qué garantiza un genérico y qué no
- Organizar el acceso a la API en servicios y mostrar los estados de carga, error y éxito con señales
- Usar
observe,responseType,HttpParamsyHttpHeaders, y reconocer un problema de CORS - Interpretar un
HttpErrorResponsey convertirlo en mensajes útiles para el usuario - Construir un CRUD completo contra la API EMPRESA, con autenticación y errores de validación
- Escribir interceptores funcionales y razonar sobre su orden
- Informar del progreso de subidas y descargas
- Reintentar con espera creciente, fijar tiempos máximos y cancelar peticiones
- Reconocer JSONP como técnica obsoleta y saber qué usar en su lugar
1. Por qué HttpClient y no fetch a secas
Casi todas las aplicaciones Angular hablan con un servidor. El navegador ya trae fetch, que viste en el Curso de JavaScript, así que la primera pregunta es razonable: ¿para qué otra herramienta?
Compara las dos formas de pedir un empleado y tratar los errores:
// Con fetch: todo es manual
const respuesta = await fetch(`${url}/empleados/100`, {
headers: { Authorization: `Bearer ${token}` }, // en cada llamada
});
if (!respuesta.ok) { // un 404 NO rechaza la promesa
throw new Error(`Error ${respuesta.status}`);
}
const empleado: Empleado = await respuesta.json(); // segundo await
// Con HttpClient
this.http.get<Empleado>(`${url}/empleados/100`).subscribe({
next: (empleado) => this.empleado.set(empleado),
error: (err: HttpErrorResponse) => this.error.set(err.status),
});
Las diferencias van más allá de escribir menos:
| Necesidad | fetch | HttpClient |
|---|---|---|
| Convertir de JSON | Manual, con await respuesta.json() | Automático |
| Un 404 o un 500 | La promesa se cumple; tienes que mirar ok | Llega por el canal de error |
| Enviar un objeto como JSON | JSON.stringify y cabecera manual | Automático |
| Token en todas las peticiones | Una función envoltorio propia | Un interceptor, en un solo sitio |
| Cancelar | AbortController | Basta con unsubscribe |
| Reintentar | Un bucle propio | El operador retry de RxJS |
| Pruebas | Simular fetch a mano | provideHttpClientTesting, en el módulo 26 |
Un Observable, no una promesa
HttpClient devuelve un Observable. El módulo 14 estudia RxJS a fondo; para este módulo basta con cuatro ideas:
- Un
Observablees la descripción de un trabajo que emitirá valores. Hasta que alguien se suscribe, no ocurre nada. subscribelo pone en marcha. Recibenextpara los valores,errorpara el fallo ycompletepara el final.pipeencadena operadores que transforman lo que pasa por él.mapcambia el valor ytapmira sin tocar, igual que asomarse a una tubería.- Las peticiones de
HttpClientemiten un valor y terminan, de modo que no hay nada que quede abierto cuando acaban bien.
Figura 1. La petición cruza varias capas. Los interceptores pueden modificarla a la ida y a la vuelta, y solo el backend toca la red.
📘 Concepto:
HttpClientes un servicio de Angular que construye un objetoHttpRequest, lo pasa por la cadena de interceptores y deja que un backend lo envíe. Te entrega el resultado como unObservable, ya convertido de JSON.
Angular tiene otra vía para leer datos: httpResource, que devuelve señales en lugar de un Observable. La verás en el módulo 13, y se apoya en todo lo de aquí, interceptores incluidos. HttpClient sigue siendo la herramienta para las escrituras y para los casos donde quieres el control completo.
2. Preparar el proyecto
La API EMPRESA
Descarga recursos/empresa-api.zip, descomprímelo y arráncalo. No tiene dependencias:
node server.mjs
La API queda en http://localhost:3000/api. Los datos viven en memoria y vuelven a su estado inicial cada vez que reinicias. Si quieres ver bien los estados de carga, simula una red lenta con la variable RETARDO:
# En Git Bash, macOS o Linux
RETARDO=800 node server.mjs
Estas son las rutas que usaremos:
| Método | Ruta | Permiso | Uso |
|---|---|---|---|
| POST | /api/auth/login | Público | Devuelve { token, usuario, expiraEn } |
| GET | /api/empleados | Público | Lista paginada, con q, orden, pagina, tamano, deptId |
| GET | /api/empleados/:id | Público | Detalle |
| POST, PUT | /api/empleados, /api/empleados/:id | Sesión | Crear y modificar |
| DELETE | /api/empleados/:id | ADMIN | Borrar (409 si tiene subordinados) |
| GET | /api/departamentos | Público | Lista paginada |
Los usuarios son ana.garcia@empresa.es (ADMIN) y luis.martinez@empresa.es (USER), los dos con la contraseña secreto.
provideHttpClient
HttpClient se puede inyectar sin configurar nada: en un proyecto recién creado, inject(HttpClient) funciona y las peticiones salen. Entonces, ¿para qué provideHttpClient? Porque es el único sitio donde se configura el cliente. En cuanto quieras interceptores o cambiar de backend, lo necesitas:
// app.config.ts
provideHttpClient(
withInterceptors([registroInterceptor, authInterceptor, sesionInterceptor]),
),
Cada opción es una función with... que se pasa como argumento:
| Función | Para qué sirve |
|---|---|
withInterceptors([...]) | Registra interceptores funcionales (sección 11) |
withXhr() | Usa XHR como backend (sección 3) |
withXsrfConfiguration({...}) | Cambia los nombres de la protección XSRF |
withNoXsrfProtection() | Desactiva esa protección |
withInterceptorsFromDi() | Admite interceptores antiguos basados en clases |
withRequestsMadeViaParent() | Delega en el HttpClient del inyector padre |
💡 Consejo: Aunque no configures nada, escribe
provideHttpClient()enapp.config.ts. Quien lea el proyecto verá de un vistazo que hay un cliente HTTP, y añadir un interceptor más tarde será cambiar una línea.
La URL base en un solo sitio
Repartir http://localhost:3000/api por el código complica cualquier cambio. Con un InjectionToken (módulo 08) hay un único punto, y cada entorno o prueba puede sustituirlo:
// src/app/empresa/api-url.ts
import { InjectionToken } from '@angular/core';
// Con factory, el token funciona sin registrarlo: 'providedIn root' va implícito
export const API_URL = new InjectionToken<string>('API_URL', {
factory: () => 'http://localhost:3000/api',
});
Durante el desarrollo, la alternativa es un proxy: ng serve --proxy-config proxy.conf.json reenvía las rutas /api a otro servidor, y la aplicación usa URL relativas. El navegador cree que todo viene del mismo origen, así que no interviene CORS (sección 8). En el módulo 27 verás cómo llevar la URL a producción.
3. El backend: fetch o XHR
El navegador ofrece dos formas de hacer una petición: XMLHttpRequest (XHR), la clásica, y fetch, la moderna. HttpClient las esconde detrás de una clase llamada backend: FetchBackend o HttpXhrBackend. Tu código no cambia de una a otra, salvo en los detalles de esta sección.
fetch por defecto 🔄 v22.0
🔄 Cambia en Angular 22.0. En la LTS (Angular 21) el backend por defecto es XHR. Si usas la LTS: no tienes que hacer nada para seguir este módulo; si quieres
fetch, añadewithFetch()aprovideHttpClient.
Lo comprobamos en un navegador real, haciendo la misma petición con cada backend y leyendo el tipo que registra Chromium:
| Versión y configuración | Tipo en la pestaña Red |
|---|---|
| Angular 22, sin opciones | fetch |
Angular 22, provideHttpClient(withXhr()) | xhr |
| Angular 21, sin opciones | xhr |
Esto es lo que cambia en la práctica:
| Capacidad | fetch (FetchBackend) | XHR (HttpXhrBackend) |
|---|---|---|
| Progreso de descarga | Sí | Sí |
| Progreso de subida | No | Sí |
Opciones keepalive, cache, priority, mode, redirect… | Sí | No se aplican |
| Funciona en servidor (SSR) | Sí, recomendado | Obsoleto, ver el aviso de abajo |
| Código de JSON, tipos, interceptores | Igual | Igual |
Las opciones propias de fetch se pasan como las demás. keepalive, por ejemplo, permite que una petición termine aunque el usuario cierre la pestaña, algo útil para enviar una última estadística:
this.http.post(`${url}/estadisticas`, datos, { keepalive: true }).subscribe();
withXhr() 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: no lo necesitas, porque XHR ya es el backend por defecto. Quita
withXhr()y el resto del código funciona igual.
Para volver a XHR se añade una función:
provideHttpClient(withXhr())
Su uso principal es el progreso de subida (sección 12). Cuando actualizas un proyecto con ng update, la migración http-xhr-backend lo añade por ti para conservar el comportamiento que tenía. Es una decisión prudente, pero conviene revisarla después: si la aplicación no muestra el progreso de las subidas, puedes quitarlo.
⚠️ Importante: No uses
withXhr()en renderizado en servidor. La documentación de la propia función avisa de que XHR en el servidor está obsoleto y de que se prevé eliminarlo en Angular 23, porque la bibliotecaxhr2que hay detrás no gestiona con seguridad las redirecciones.
withFetch() obsoleto 🔄 v22.0
🔄 Cambia en Angular 22.0. En la LTS (Angular 21)
withFetch()no está obsoleto, y es la forma de activarfetch. Si usas la LTS: mantenlo si quieresfetch, y bórralo al pasar a Angular 22, donde ya no hace falta.
Verás withFetch() en muchísimo código de los últimos años. En Angular 22 sigue compilando, pero no aporta nada: el valor por defecto ya es fetch.
4. Tipar las respuestas
La API envuelve los listados en un objeto con los datos de paginación. Antes de pedir nada, describimos lo que llega:
// src/app/empresa/modelos.ts
// Lo que devuelve la API en cualquier listado
export interface Pagina<T> {
contenido: T[];
pagina: number;
tamano: number;
total: number;
paginas: number;
}
export interface Empleado {
empId: number;
nombre: string;
apellidos: string;
email: string;
telefono: string | null;
fechaAlta: string;
oficio: string;
salario: number;
comision: number | null;
jefeId: number | null;
deptId: number | null;
departamento?: string; // nombre, solo en las respuestas
}
// Lo que se envía al crear o modificar: el id lo decide el servidor
export type DatosEmpleado = Pick<
Empleado,
'nombre' | 'apellidos' | 'email' | 'oficio' | 'salario' | 'deptId'
>;
export interface Departamento {
deptId: number;
nombre: string;
localidad: string;
presupuesto: number;
}
export interface Usuario {
email: string;
nombre: string;
rol: 'ADMIN' | 'USER';
}
export interface Sesion {
token: string;
usuario: Usuario;
expiraEn: number;
}
// Forma de todos los errores de la API
export interface ErrorApi {
status: number;
mensaje: string;
detalles?: Record<string, string>;
}
Pagina<T> es un tipo genérico (módulo 02): sirve para empleados, departamentos o lo que venga. DatosEmpleado se deriva de Empleado con Pick, así que si cambias un campo en un sitio, el compilador lo señala en el otro.
Con esos tipos, el genérico de get hace el resto:
this.http.get<Pagina<Empleado>>(`${url}/empleados`)
// Observable<Pagina<Empleado>>
this.http.get(`${url}/empleados`)
// Observable<Object>: no podrás leer ninguna propiedad
Lo que el genérico no hace
⚠️ Importante:
get<Empleado>es una promesa tuya al compilador, no una comprobación. Los tipos desaparecen al compilar. Si la API devuelve otra cosa, TypeScript no se entera y el fallo saltará después, en otro punto del código.
El error típico nace de esa confianza. Esta línea compila, pero el resultado es falso:
// La API devuelve un objeto { contenido, pagina, ... }, NO un array
this.http.get<Empleado[]>(`${url}/empleados`).subscribe((empleados) => {
this.total.set(empleados.length); // undefined en tiempo de ejecución
});
Mira siempre la respuesta real en la pestaña Red de las herramientas del navegador antes de escribir el tipo. Si los datos vienen de una fuente que no controlas, valídalos al llegar con una guarda de tipos como la de la sección 9, o con una biblioteca de esquemas.
5. Del servicio a la pantalla
Un componente no debería saber qué URL tiene la API. Lo normal es un servicio por recurso, con un método por operación. El componente pide datos en su idioma (listar, crear) y decide qué hacer con ellos.
Un servicio por recurso 🆕 v22.0
🆕 Desde Angular 22.0.
@Service()no existe en la LTS (Angular 21). Si usas la LTS: en todos los servicios de este módulo, escribe@Injectable({ providedIn: 'root' })en lugar de@Service()e importaInjectablede@angular/core. El resto del código es igual.
// src/app/empresa/empleados-api.ts
import { HttpClient } from '@angular/common/http';
import { inject, Service } from '@angular/core';
import { Observable } from 'rxjs';
import { API_URL } from './api-url';
import { DatosEmpleado, Empleado, Pagina } from './modelos';
import { parametros } from './parametros';
export interface FiltroEmpleados {
q?: string;
deptId?: number;
orden?: string;
pagina?: number;
tamano?: number;
}
@Service()
export class EmpleadosApi {
private readonly http = inject(HttpClient);
private readonly url = `${inject(API_URL)}/empleados`;
listar(filtro: FiltroEmpleados = {}): Observable<Pagina<Empleado>> {
return this.http.get<Pagina<Empleado>>(this.url, {
params: parametros({ ...filtro }),
});
}
obtener(id: number): Observable<Empleado> {
return this.http.get<Empleado>(`${this.url}/${id}`);
}
crear(datos: DatosEmpleado): Observable<Empleado> {
return this.http.post<Empleado>(this.url, datos);
}
actualizar(id: number, datos: DatosEmpleado): Observable<Empleado> {
return this.http.put<Empleado>(`${this.url}/${id}`, datos);
}
borrar(id: number): Observable<void> {
return this.http.delete<void>(`${this.url}/${id}`);
}
}
Fíjate en lo que no hace el servicio: no se suscribe. Devuelve el Observable y deja que quien llama decida cuándo lanzarlo, cómo combinarlo y qué hacer si falla. La función parametros la veremos en la sección 7.
Los tres estados de una petición
Una petición siempre está en uno de tres estados: en curso, con error o con datos. La pantalla debe mostrar cada uno, y tres señales lo resuelven con claridad:
// src/app/paginas/lista-empleados.ts
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EmpleadosApi } from '../empresa/empleados-api';
import { mensajeDeError } from '../empresa/errores';
import { Empleado, Pagina } from '../empresa/modelos';
@Component({
selector: 'app-lista-empleados',
template: `
<h2>Empleados</h2>
<form (submit)="buscar($event, texto.value)">
<input #texto type="search" placeholder="Buscar por nombre..." aria-label="Buscar" />
<button type="submit">Buscar</button>
</form>
@if (cargando()) {
<p class="aviso" role="status">Cargando empleados...</p>
} @else if (error(); as mensaje) {
<p class="fallo" role="alert">{{ mensaje }}</p>
<button type="button" (click)="cargar()">Reintentar</button>
} @else if (pagina(); as resultado) {
<p>{{ resultado.total }} empleados encontrados</p>
<table>
<thead>
<tr><th>Id</th><th>Nombre</th><th>Oficio</th><th>Salario</th></tr>
</thead>
<tbody>
@for (e of resultado.contenido; track e.empId) {
<tr>
<td>{{ e.empId }}</td>
<td>{{ e.nombre }} {{ e.apellidos }}</td>
<td>{{ e.oficio }}</td>
<td>{{ e.salario }} €</td>
</tr>
} @empty {
<tr><td colspan="4">Sin resultados</td></tr>
}
</tbody>
</table>
}
`,
})
export class ListaEmpleados {
private readonly api = inject(EmpleadosApi);
private readonly destroyRef = inject(DestroyRef);
protected readonly pagina = signal<Pagina<Empleado> | null>(null);
protected readonly cargando = signal(true);
protected readonly error = signal<string | null>(null);
private q = '';
constructor() {
this.cargar();
}
protected buscar(evento: Event, texto: string) {
evento.preventDefault(); // el formulario no debe recargar la página
this.q = texto;
this.cargar();
}
protected cargar() {
this.cargando.set(true);
this.error.set(null);
this.api
.listar({ q: this.q, tamano: 8, orden: 'apellidos' })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (pagina) => {
this.pagina.set(pagina);
this.cargando.set(false);
},
error: (err) => {
this.error.set(mensajeDeError(err));
this.cargando.set(false);
},
});
}
}

Figura 2. Estado de carga, con la respuesta retenida 900 ms.

Figura 3. Estado con datos. El total llega de la API; la tabla muestra solo los ocho de la página.

Figura 4. Estado de error, con la petición interrumpida a propósito. Justo debajo, fuera de la imagen, queda el botón Reintentar.
Conviene repasar cuatro detalles:
- Las señales no son opcionales. Con la aplicación sin
zone.js, Angular actualiza la vista cuando cambia una señal que la plantilla lee. Si guardas el resultado en una propiedad corriente (this.pagina = pagina), los datos llegan pero la pantalla no se entera. Lo explica el módulo 09. takeUntilDestroyedcancela la suscripción al destruirse el componente. Si el usuario cambia de pantalla con la petición en marcha, esta se aborta. El módulo 15 lo trata a fondo.preventDefaultevita que elsubmitdel formulario recargue la página.- El
@else ifencadena los estados para que nunca se pisen. Un error no deja una tabla vieja a la vista.
💡 Consejo: Si te cansa escribir estas tres señales una y otra vez, estás en lo cierto: es exactamente lo que resuelve
httpResourceen el módulo 13. Hazlo a mano aquí para entender lo que esa función te ahorra.
Sin suscripción no hay petición
Un Observable de HttpClient es frío: cada suscripción lanza una petición nueva, y sin suscripción no se lanza ninguna. Contamos las peticiones con un interceptor para comprobarlo:
const peticion = this.http.get(`${url}/categorias`);
// Peticiones hechas: 0
peticion.subscribe();
peticion.subscribe();
// Peticiones hechas: 2
De ahí salen dos errores muy frecuentes:
// MAL: el borrado no ocurre nunca, nadie se ha suscrito
borrar(id: number) {
this.api.borrar(id);
}
// BIEN
borrar(id: number) {
this.api.borrar(id).subscribe();
}
<!-- MAL: dos | async sobre el mismo Observable hacen dos peticiones -->
<p>{{ (empleado$ | async)?.nombre }}</p>
<p>{{ (empleado$ | async)?.oficio }}</p>
Para el segundo caso hay varias salidas: guardar el resultado en una señal, como hemos hecho, usar @if (empleado$ | async; as empleado), o compartir con shareReplay, que verás en el módulo 14.
6. Más opciones de la petición
observe: cuánto de la respuesta quieres
Por defecto recibes solo el cuerpo. La opción observe amplía lo que llega:
| Valor | Recibes | Uso típico |
|---|---|---|
'body' (por defecto) | El cuerpo ya convertido | Casi siempre |
'response' | Un HttpResponse, con estado y cabeceras | Leer una cabecera o distinguir códigos |
'events' | Cada evento del ciclo de la petición | Progreso (sección 12) |
this.http
.get<Pagina<Empleado>>(`${url}/empleados`, {
observe: 'response',
params: { tamano: 1 },
})
.subscribe((r) => {
console.log(r.status, r.ok, r.headers.get('Content-Type'), r.body?.total);
});
200 true application/json; charset=utf-8 24
⚠️ Importante: Entre orígenes distintos, el navegador solo deja leer unas pocas cabeceras. En la prueba anterior,
r.headers.keys()devolvió únicamente["content-type"], aunque el servidor envía más. Si tu API añade una cabecera propia que deba leer el cliente, debe declararla conAccess-Control-Expose-Headers.
responseType: cómo interpretar el cuerpo
Por defecto se espera JSON. Para otros formatos, responseType lo cambia. El tipo de retorno se ajusta solo, y por eso no lleva genérico:
| Valor | Tipo que recibes | Uso típico |
|---|---|---|
'json' (por defecto) | El objeto convertido | API |
'text' | string | CSV, XML, plantillas |
'blob' | Blob | Descargar archivos, imágenes |
'arraybuffer' | ArrayBuffer | Datos binarios que vas a procesar |
this.http.get(`${url}/empleados/100`, { responseType: 'text' })
.subscribe((texto) => console.log(texto.slice(0, 40)));
this.http.get(`${url}/empleados/100`, { responseType: 'blob' })
.subscribe((b) => console.log(b.size, b.type));
{"empId":100,"nombre":"Ana","apellidos":
250 application/json; charset=utf-8
TypeScript exige que responseType y observe se vean como literales. Si los guardas antes en una variable, escribe as const o déjalos dentro de la llamada.
7. Parámetros de consulta: HttpParams
Puedes pasar un objeto simple, y es lo más cómodo si los valores son fijos:
this.http.get(url, { params: { orden: 'nombre', tamano: 10, activos: true } });
Para construirlos paso a paso está HttpParams. Su rasgo principal es que es inmutable: cada método devuelve un objeto nuevo y deja intacto el original.
const mal = new HttpParams();
mal.set('q', 'ana'); // el resultado se pierde
console.log(mal.toString()); // ""
const bien = new HttpParams().set('q', 'ana').set('tamano', 5);
console.log(bien.toString()); // "q=ana&tamano=5"
| Método | Efecto |
|---|---|
set(clave, valor) | Sustituye el valor |
append(clave, valor) | Añade otro valor a la misma clave |
delete(clave) | Quita la clave |
get, getAll, has, keys | Consultan |
toString() | Cadena lista para la URL, codificada |
HttpParams codifica lo que haga falta, así que no concatenes parámetros en la URL. Un & escrito por el usuario rompería la consulta, y hasta podría colar un parámetro nuevo. Compruébalo con un texto problemático y con valores repetidos:
new HttpParams().set('q', 'a+b c/d ñ&=').append('t', 1).append('t', 2).toString();
new HttpParams({ fromObject: { deptId: [10, 20], q: 'x' } }).toString();
q=a%2Bb%20c/d%20%C3%B1%26=&t=1&t=2
deptId=10&deptId=20&q=x
Filtros con campos vacíos
Un filtro de pantalla suele traer campos sin rellenar, y enviar ?q=&deptId= no es lo mismo que no enviarlos. Esta función descarta lo vacío:
// src/app/empresa/parametros.ts
import { HttpParams } from '@angular/common/http';
type Valor = string | number | boolean | null | undefined;
// Convierte un filtro en HttpParams y descarta lo vacío
export function parametros(filtro: Record<string, Valor>): HttpParams {
let params = new HttpParams();
for (const [clave, valor] of Object.entries(filtro)) {
if (valor !== null && valor !== undefined && valor !== '') {
params = params.set(clave, valor); // set devuelve un objeto NUEVO
}
}
return params;
}
Comprueba null, undefined y '' de forma explícita. Con un simple if (valor) también perderías el 0 y el false, que son valores válidos:
parametros({ q: '', deptId: undefined, tamano: 5, activo: false, pagina: 0 }).toString();
// "tamano=5&activo=false&pagina=0"
8. Cabeceras y CORS
HttpHeaders también es inmutable, y sus nombres no distinguen mayúsculas. Como en los parámetros, si la cabecera es fija te basta un objeto:
this.http.get(url, { headers: { Accept: 'application/json' } });
const cabeceras = new HttpHeaders({ 'X-A': '1' })
.append('X-A', '2')
.set('Accept', 'application/json');
cabeceras.getAll('X-A'); // ["1", "2"]
cabeceras.get('x-a'); // "1": devuelve solo el primero
cabeceras.keys(); // ["X-A", "Accept"]
No hace falta poner Content-Type: application/json al enviar un objeto: HttpClient lo deduce del cuerpo. Tampoco debes fijarlo al enviar un FormData, porque el navegador tiene que añadir por su cuenta la marca que separa las partes.
Por qué una cabecera puede tumbar una petición
Este es uno de los problemas que más desconciertan. Añades una cabecera inocente a una petición que funcionaba:
this.http.get(`${url}/empleados`, { headers: { 'X-Cliente': 'empresa' } });
Y falla con status: 0, sin más pistas en el código:
HttpErrorResponse { status: 0, statusText: "Unknown Error", ok: false }
La causa es CORS, la regla por la que un navegador protege a un servidor de las llamadas hechas desde otras webs. Tu aplicación vive en localhost:4200 y la API en localhost:3000: al cambiar el puerto cambia el origen. Si la petición lleva cabeceras fuera de una lista reducida, el navegador pregunta antes con una petición OPTIONS, llamada petición previa (preflight). La API EMPRESA solo permite Content-Type y Authorization, así que el navegador corta ahí.
Figura 5. El GET real nunca sale. La consola del navegador explica el motivo, pero tu código solo ve status: 0.
| Cabecera añadida | Resultado contra EMPRESA | Motivo |
|---|---|---|
Authorization | Funciona | Está en Access-Control-Allow-Headers |
Accept-Language | Funciona | Es de las que no exigen permiso |
X-Cliente | status: 0 | No está en la lista del servidor |
⚠️ Importante: CORS se arregla en el servidor, nunca desde Angular. Ninguna cabecera que añadas en el cliente te dará permiso: el permiso es del servidor. En desarrollo también puedes usar el proxy de la sección 2. Y recuerda que CORS solo lo aplican los navegadores: por eso la misma petición funciona con
curlo Postman y te hace pensar que el problema está en otro sitio.
9. Errores
Si una petición falla, el Observable termina por error con un HttpErrorResponse. Estas son las propiedades que usarás, con los valores reales de pedir un empleado que no existe:
| Propiedad | Valor |
|---|---|
status | 404 |
statusText | "Not Found" |
ok | false |
url | La URL de la petición |
message | "Http failure response for .../empleados/9999: 404 Not Found" |
error | El cuerpo: { status: 404, mensaje: "Empleado 9999 no encontrado" } |
headers | Las cabeceras legibles |
No confundas err.message, que escribe Angular para el programador, con err.error, que es lo que ha enviado el servidor. Al usuario se le muestra lo segundo, nunca lo primero.
Figura 6. Lo primero que hay que mirar de un error es status.
Así responde la API EMPRESA a cada situación, según nuestras pruebas:
| Situación | status | err.error.mensaje |
|---|---|---|
| Id inexistente | 404 | Empleado 9999 no encontrado |
| Ruta inexistente | 404 | Ruta no encontrada: /api/noexiste |
| Escribir sin sesión | 401 | Debes iniciar sesión |
| Contraseña incorrecta | 401 | Email o contraseña incorrectos |
| Borrar con rol USER | 403 | Solo un administrador puede hacer esto |
| Borrar a quien tiene subordinados | 409 | No se puede borrar a un empleado que tiene subordinados |
| Datos no válidos | 422 | Hay datos no válidos, más detalles por campo |
Convertir errores en mensajes
Como todos los errores de la API tienen la misma forma, una función los traduce en un solo sitio. Por el camino, una guarda de tipos comprueba de verdad lo que llega en err.error, que TypeScript declara como any:
// src/app/empresa/errores.ts
import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http';
import { ErrorApi } from './modelos';
// Guarda de tipos: comprueba de verdad la forma del cuerpo de error
export function esErrorApi(cuerpo: unknown): cuerpo is ErrorApi {
return (
typeof cuerpo === 'object' &&
cuerpo !== null &&
typeof (cuerpo as ErrorApi).mensaje === 'string'
);
}
export function mensajeDeError(err: unknown): string {
if (!(err instanceof HttpErrorResponse)) {
return 'Ha ocurrido un error inesperado';
}
if (err.error?.name === 'TimeoutError') {
return 'El servidor tarda demasiado en responder.';
}
if (err.status === 0) {
return 'No se puede contactar con el servidor. Revisa tu conexión.';
}
if (esErrorApi(err.error)) {
return err.error.mensaje; // 401, 403, 404, 409, 422: la API ya lo explica
}
if (err.status >= 200 && err.status < 300) {
return 'La respuesta del servidor no tiene el formato esperado.';
}
return `El servidor ha respondido con un error (${err.status})`;
}
// Errores campo a campo de un 422; vacío en cualquier otro caso
export function erroresDeCampos(err: unknown): Record<string, string> {
if (
err instanceof HttpErrorResponse &&
err.status === HttpStatusCode.UnprocessableEntity &&
esErrorApi(err.error)
) {
return err.error.detalles ?? {};
}
return {};
}
Hay un caso curioso en el código: un error con status 200. Si el servidor responde bien pero el cuerpo no es JSON válido, ok es false y err.status sigue siendo 200. Suele ocurrir con un proxy o un portal cautivo que devuelve una página HTML, o cuando la URL está mal escrita y el servidor entrega index.html.
Qué hay dentro de err.error 🔄 v22.0
🔄 Cambia en Angular 22.0. Con el backend
fetchpor defecto,err.errortrae objetos distintos a los de la LTS (Angular 21) en los fallos de red y de formato. Si usas la LTS: no compares conTypeErrorni conSyntaxError; basa la lógica enerr.statusy enerr.error?.name === 'TimeoutError', que dan lo mismo en los dos backends, como hacemensajeDeError.
| Fallo | fetch (22, por defecto) | XHR (21, por defecto) |
|---|---|---|
| Servidor inalcanzable o CORS | TypeError: Failed to fetch | ProgressEvent |
| Tiempo agotado | DOMException TimeoutError, statusText Unknown Error | DOMException TimeoutError, statusText Request timeout |
| HTML en lugar de JSON | SyntaxError | { error: SyntaxError, text: "<!doctype..." } |
Dónde tratar el error
Tienes tres sitios, y cada uno tiene su función:
| Dónde | Cuándo |
|---|---|
error de subscribe | Lo normal: el componente decide qué ve el usuario |
catchError en el servicio | Traducir un error o sustituirlo por un valor aceptable |
| Un interceptor | Lo que afecta a toda la aplicación, como un 401 |
catchError recibe el error y debe devolver un nuevo Observable: con of(valor) lo convierte en éxito, y con throwError lo deja seguir. Por ejemplo, un método que trata “no existe” como un resultado normal:
buscar(id: number): Observable<Empleado | null> {
return this.obtener(id).pipe(
catchError((err: HttpErrorResponse) =>
err.status === 404 ? of(null) : throwError(() => err),
),
);
}
⚠️ Importante: Si te suscribes sin función de error y la petición falla, el error se lanza como no capturado y acaba en la consola, sin que el usuario vea nada. Toda petición de la que dependa la pantalla debe tratar su error.
10. CRUD contra EMPRESA
Ya tienes todas las piezas para leer y escribir. Falta iniciar sesión, porque las escrituras exigen un token.
Iniciar sesión
La API devuelve un JWT (JSON Web Token), una cadena firmada que demuestra quién eres, y hay que enviarlo en cada escritura. Guardamos la sesión en una señal:
// src/app/empresa/auth.ts
import { HttpClient, HttpContext } from '@angular/common/http';
import { computed, inject, Service, signal } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { SIN_TOKEN } from '../interceptores/contexto';
import { API_URL } from './api-url';
import { Sesion } from './modelos';
@Service()
export class Auth {
private readonly http = inject(HttpClient);
private readonly url = `${inject(API_URL)}/auth/login`;
private readonly sesion = signal<Sesion | null>(null);
readonly usuario = computed(() => this.sesion()?.usuario ?? null);
readonly token = computed(() => this.sesion()?.token ?? null);
entrar(email: string, password: string): Observable<Sesion> {
return this.http
.post<Sesion>(
this.url,
{ email, password },
{ context: new HttpContext().set(SIN_TOKEN, true) },
)
.pipe(tap((sesion) => this.sesion.set(sesion)));
}
salir(): void {
this.sesion.set(null);
}
}
tap guarda la sesión sin alterar lo que recibe quien llama, y por eso entrar sigue devolviendo la Sesion. SIN_TOKEN es un marcador que se explica en la sección 11. La sesión vive solo en memoria y se pierde al recargar; el módulo 29 trata la persistencia.
Cada verbo, su código
| Operación | Método del servicio | Petición | Éxito | Qué recibes |
|---|---|---|---|---|
| Listar | listar(filtro) | GET /empleados?... | 200 | Pagina<Empleado> |
| Detalle | obtener(id) | GET /empleados/100 | 200 | Empleado |
| Crear | crear(datos) | POST /empleados | 201 | El Empleado con su id |
| Modificar | actualizar(id, datos) | PUT /empleados/161 | 200 | El Empleado actualizado |
| Borrar | borrar(id) | DELETE /empleados/161 | 204 | Nada: null |
Comprobado de principio a fin contra la API, con una sesión de ADMIN:
POST http://localhost:3000/api/auth/login -> 200 (17 ms)
POST http://localhost:3000/api/empleados -> 201 (15 ms)
PUT http://localhost:3000/api/empleados/161 -> 200 (13 ms)
DELETE http://localhost:3000/api/empleados/161 -> 204 (12 ms)
Pantalla de gestión
Los formularios de Angular llegan en los módulos 16 a 18, así que aquí enlazamos los campos a mano, con una señal que guarda los datos. Lo importante es el diálogo con la API:
// src/app/paginas/gestion-empleados.ts
import { Component, inject, signal } from '@angular/core';
import { Auth } from '../empresa/auth';
import { EmpleadosApi } from '../empresa/empleados-api';
import { erroresDeCampos, mensajeDeError } from '../empresa/errores';
import { DatosEmpleado, Empleado } from '../empresa/modelos';
const VACIO: DatosEmpleado = {
nombre: '', apellidos: '', email: '', oficio: '', salario: 0, deptId: 40,
};
@Component({
selector: 'app-gestion-empleados',
template: `
<h2>Gestión de empleados</h2>
@if (auth.usuario(); as usuario) {
<p>
Sesión de {{ usuario.nombre }} ({{ usuario.rol }})
<button type="button" (click)="auth.salir()">Salir</button>
</p>
} @else {
<p>
<button type="button" (click)="entrar('ana.garcia@empresa.es')">Entrar como ADMIN</button>
<button type="button" (click)="entrar('luis.martinez@empresa.es')">Entrar como USER</button>
</p>
}
@if (error(); as mensaje) {
<p class="fallo" role="alert">{{ mensaje }}</p>
}
<form class="ficha" (submit)="guardar($event)">
<h3>{{ editando() ? 'Modificar a ' + editando() : 'Nuevo empleado' }}</h3>
@for (campo of campos; track campo.clave) {
<label>
{{ campo.titulo }}
<input
[type]="campo.tipo"
[value]="datos()[campo.clave]"
[attr.aria-invalid]="!!campos422()[campo.clave]"
(input)="cambiar(campo.clave, $event)"
/>
@if (campos422()[campo.clave]; as fallo) {
<small class="fallo">{{ fallo }}</small>
}
</label>
}
<button type="submit" [disabled]="guardando()">
{{ guardando() ? 'Guardando...' : 'Guardar' }}
</button>
<button type="button" (click)="limpiar()">Limpiar</button>
</form>
<table>
<thead>
<tr><th>Id</th><th>Nombre</th><th>Oficio</th><th>Salario</th><th></th></tr>
</thead>
<tbody>
@for (e of empleados(); track e.empId) {
<tr>
<td>{{ e.empId }}</td>
<td>{{ e.nombre }} {{ e.apellidos }}</td>
<td>{{ e.oficio }}</td>
<td>{{ e.salario }} €</td>
<td>
<button type="button" (click)="editar(e)">Editar</button>
<button type="button" (click)="borrar(e)">Borrar</button>
</td>
</tr>
}
</tbody>
</table>
`,
})
export class GestionEmpleados {
private readonly api = inject(EmpleadosApi);
protected readonly auth = inject(Auth);
protected readonly campos = [
{ clave: 'nombre', titulo: 'Nombre', tipo: 'text' },
{ clave: 'apellidos', titulo: 'Apellidos', tipo: 'text' },
{ clave: 'email', titulo: 'Correo', tipo: 'text' },
{ clave: 'oficio', titulo: 'Oficio', tipo: 'text' },
{ clave: 'salario', titulo: 'Salario', tipo: 'number' },
] as const;
protected readonly empleados = signal<Empleado[]>([]);
protected readonly datos = signal<DatosEmpleado>(VACIO);
protected readonly editando = signal<number | null>(null);
protected readonly guardando = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly campos422 = signal<Record<string, string>>({});
constructor() {
this.recargar();
}
protected entrar(email: string) {
this.auth.entrar(email, 'secreto').subscribe({
next: () => this.error.set(null),
error: (err) => this.error.set(mensajeDeError(err)),
});
}
protected cambiar(clave: keyof DatosEmpleado, evento: Event) {
const { value, valueAsNumber } = evento.target as HTMLInputElement;
const valor = clave === 'salario' ? valueAsNumber : value;
this.datos.update((d) => ({ ...d, [clave]: valor }));
}
protected editar(e: Empleado) {
const { nombre, apellidos, email, oficio, salario, deptId } = e;
this.datos.set({ nombre, apellidos, email, oficio, salario, deptId });
this.editando.set(e.empId);
this.campos422.set({});
}
protected limpiar() {
this.datos.set(VACIO);
this.editando.set(null);
this.campos422.set({});
this.error.set(null);
}
protected guardar(evento: Event) {
evento.preventDefault();
this.guardando.set(true);
this.error.set(null);
this.campos422.set({});
const id = this.editando();
const peticion =
id === null ? this.api.crear(this.datos()) : this.api.actualizar(id, this.datos());
peticion.subscribe({
next: (guardado) => {
// La API devuelve el empleado completo: no hace falta pedir la lista
this.empleados.update((lista) =>
id === null
? [guardado, ...lista]
: lista.map((e) => (e.empId === id ? guardado : e)),
);
this.guardando.set(false);
this.limpiar();
},
error: (err) => {
this.guardando.set(false);
this.error.set(mensajeDeError(err));
this.campos422.set(erroresDeCampos(err));
},
});
}
protected borrar(e: Empleado) {
this.error.set(null);
this.api.borrar(e.empId).subscribe({
next: () => this.empleados.update((lista) => lista.filter((x) => x.empId !== e.empId)),
error: (err) => this.error.set(mensajeDeError(err)),
});
}
private recargar() {
this.api.listar({ tamano: 6, orden: '-empId' }).subscribe({
next: (pagina) => this.empleados.set(pagina.contenido),
error: (err) => this.error.set(mensajeDeError(err)),
});
}
}

Figura 7. Un 422 real. La API detecta los fallos y erroresDeCampos coloca cada mensaje bajo su campo.
Algunas decisiones merecen un comentario:
- El mismo
guardarcrea o modifica. Según la señaleditando, elige la petición. Las dos devuelvenObservable<Empleado>, de modo que elsubscribees uno solo. - Actualizar la lista sin volver a pedirla. La API devuelve el empleado completo, así que basta con
update. Es más rápido y evita el parpadeo. Si otros usuarios pueden cambiar los datos a la vez, puede convenirte recargar. - Aquí no hay
takeUntilDestroyed. Cancelar una lectura no tiene coste; cancelar una escritura porque el usuario cambió de pantalla deja la duda de si llegó al servidor. Además, estas peticiones terminan solas. - Desactivar el botón mientras se guarda. Dos clics rápidos serían dos
POSTy dos empleados. - Validar también en el servidor. Aunque el formulario valide (módulos 16 a 18), el servidor tiene la última palabra. Comprobar un email duplicado, por ejemplo, solo puede hacerlo él.
💡 Consejo: El 422 se trata con los dos niveles de error: un mensaje general y otro por campo. Prueba también a pulsar Guardar sin sesión (
Debes iniciar sesión) y a borrar como USER (Solo un administrador puede hacer esto).
11. Interceptores funcionales
Hasta aquí, algo no encaja: ¿quién pone el token en las peticiones? En EmpleadosApi no aparece por ningún lado, y no debería. Repartir el token por cada método sería repetitivo y fácil de olvidar.
Un interceptor es una función por la que pasan todas las peticiones del cliente. Recibe la petición y una función next que la entrega al siguiente eslabón:
import { HttpInterceptorFn } from '@angular/common/http';
export const miInterceptor: HttpInterceptorFn = (req, next) => {
// 1. Aquí, antes de que salga
return next(req); // 2. Devuelve el Observable de la respuesta
};
Tiene tres posibilidades: dejarla pasar, modificarla o responder sin llegar a la red. Se registra en app.config.ts, y es lo que faltaba para unir todo el módulo:
// src/app/app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './interceptores/auth';
import { registroInterceptor } from './interceptores/registro';
import { sesionInterceptor } from './interceptores/sesion';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(
withInterceptors([registroInterceptor, authInterceptor, sesionInterceptor]),
),
],
};
Añadir el token
HttpRequest es inmutable. Para cambiarla se crea una copia con clone:
// src/app/interceptores/auth.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { API_URL } from '../empresa/api-url';
import { Auth } from '../empresa/auth';
import { SIN_TOKEN } from './contexto';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(Auth).token();
const esNuestraApi = req.url.startsWith(inject(API_URL));
// El token solo viaja a nuestra API, y nunca si la petición lo descarta
if (!token || !esNuestraApi || req.context.get(SIN_TOKEN)) {
return next(req);
}
const conToken = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
return next(conToken);
};
clone acepta atajos muy prácticos, como setHeaders, setParams, headers, url y body.
⚠️ Importante: Comprueba siempre el destino antes de enviar el token. Si tu aplicación consulta también una API de terceros y el interceptor añade el token sin mirar, le estás regalando la sesión del usuario a ese servicio.
esNuestraApiexiste por eso.
Reaccionar a la respuesta
Lo que hay en el pipe de next(req) se ejecuta a la vuelta. Este interceptor mide cada petición y la anota en la consola:
// src/app/interceptores/registro.ts
import { HttpEventType, HttpInterceptorFn } from '@angular/common/http';
import { finalize, tap } from 'rxjs';
export const registroInterceptor: HttpInterceptorFn = (req, next) => {
const inicio = performance.now();
let resultado = 'cancelada';
return next(req).pipe(
tap({
next: (evento) => {
if (evento.type === HttpEventType.Response) {
resultado = String(evento.status);
}
},
error: (err) => (resultado = `error ${err.status}`),
}),
finalize(() => {
const ms = Math.round(performance.now() - inicio);
console.log(`${req.method} ${req.urlWithParams} -> ${resultado} (${ms} ms)`);
}),
);
};
Por un interceptor pasan todos los eventos, no solo la respuesta final. Por eso se comprueba evento.type. finalize se ejecuta siempre al acabar, incluida la cancelación, algo que no conseguirías solo con tap.
Este otro se encarga de la sesión caducada. Observa que atrapa el error, actúa y lo vuelve a lanzar, de modo que el componente sigue enterándose:
// src/app/interceptores/sesion.ts
import { HttpErrorResponse, HttpInterceptorFn, HttpStatusCode } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { Auth } from '../empresa/auth';
export const sesionInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(Auth); // aquí, no dentro de catchError
return next(req).pipe(
catchError((err: unknown) => {
if (err instanceof HttpErrorResponse && err.status === HttpStatusCode.Unauthorized) {
auth.salir(); // un token caducado no sirve: se olvida
}
return throwError(() => err); // el error sigue su camino
}),
);
};
⚠️ Importante:
inject()solo funciona al principio del interceptor, de forma síncrona. Dentro decatchErroro detapya no estás en un contexto de inyección, y Angular lanzaNG0203: The _Auth token injection failed. inject() function must be called from an injection context. Por esoauthse obtiene arriba del todo.
El orden importa
Se ejecutan en el orden del array a la ida, y en el orden inverso a la vuelta, como capas de una cebolla. Con tres interceptores que anotan su paso, la traza real es:
ida A, ida B, ida C, vuelta C, vuelta B, vuelta A
Figura 8. El primero del array es la capa exterior: ve la petición antes que nadie y la respuesta después que nadie.
Por eso registroInterceptor va el primero: su cronómetro abarca todo lo demás. Un interceptor de reintentos, por ejemplo, debe ir después de ese registro si quieres ver una sola línea por operación, y antes si quieres una por intento. Cada uno observa solo lo que hay a su derecha.
Pasar datos a un interceptor: HttpContext
A veces una petición concreta necesita tratarse de otro modo. El inicio de sesión no debe llevar token, pero una cabecera inventada para avisarlo acabaría viajando al servidor y, como viste, tumbando la petición por CORS. Para eso existe el contexto, un almacén que acompaña a la petición y nunca sale del navegador:
// src/app/interceptores/contexto.ts
import { HttpContextToken } from '@angular/common/http';
// El valor de la función es el que se lee si nadie lo ha fijado
export const SIN_TOKEN = new HttpContextToken<boolean>(() => false);
Se fija al hacer la petición, como en Auth.entrar:
{ context: new HttpContext().set(SIN_TOKEN, true) }
Y el interceptor lo lee con req.context.get(SIN_TOKEN).
Sobre qué actúan y sobre qué no
- Solo afectan a las peticiones de
HttpClient. Unfetchdirecto o un<img src>no los atraviesan. - Un
HttpClientcreado en otro nivel de inyectores no hereda los interceptores del raíz (sección 12). - Con
retryen el servicio, cada intento recorre la cadena entera. Conretrydentro de un interceptor, la función de los que van detrás no se repite: solo se vuelve a suscribir lo que devolvieron (sección 13).
Si lees código antiguo, verás clases que implementan HttpInterceptor, con un método intercept, registradas con HTTP_INTERCEPTORS. Siguen funcionando si añades withInterceptorsFromDi(), pero el código nuevo debe usar funciones, que son más simples y tienen un orden predecible. Lo retomamos en el módulo 28.
12. Progreso de subidas y descargas
Con observe: 'events', recibes cada paso de la petición. Una petición normal da solo dos eventos:
["Sent", "Response"]
HttpEventType | Cuándo llega | Qué aporta |
|---|---|---|
Sent | Al iniciarse | Nada más que el aviso |
UploadProgress | Mientras se sube | loaded y total |
ResponseHeader | Al llegar las cabeceras | Estado y cabeceras |
DownloadProgress | Mientras se descarga | loaded y total |
Response | Al final | La respuesta completa |
User | Si lo emite un interceptor | Lo que este quiera |
Para probarlo hace falta un servidor con archivos grandes, y la API EMPRESA no los maneja. Este pequeño servidor, en su propio fichero y sin dependencias, recibe subidas y sirve una descarga lenta. Déjalo funcionando junto a la API:
// node servidor-archivos.mjs (en http://localhost:3001)
import { createServer } from 'node:http';
const PORT = process.env.PORT ?? 3001;
const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type',
};
const BLOQUE = Buffer.alloc(100 * 1024, 'x'); // 100 KiB
const BLOQUES = 50; // unos 5 MB en total
createServer(async (req, res) => {
if (req.method === 'OPTIONS') {
res.writeHead(204, CORS).end();
} else if (req.method === 'POST' && req.url === '/subir') {
let bytes = 0;
for await (const trozo of req) bytes += trozo.length;
res.writeHead(200, { ...CORS, 'Content-Type': 'application/json' });
res.end(JSON.stringify({ bytes }));
} else if (req.method === 'GET' && req.url === '/informe') {
res.writeHead(200, {
...CORS,
'Content-Type': 'application/octet-stream',
'Content-Length': BLOQUE.length * BLOQUES, // sin esto no hay porcentaje
});
for (let i = 0; i < BLOQUES; i++) {
res.write(BLOQUE);
await new Promise((r) => setTimeout(r, 40)); // simula una red lenta
}
res.end();
} else {
res.writeHead(404, CORS).end();
}
}).listen(PORT, () => console.log(`Archivos en http://localhost:${PORT}`));
Opciones de progreso 🆕 v22.0
🆕 Desde Angular 22.0.
reportUploadProgressyreportDownloadProgressno existen en la LTS (Angular 21), yreportProgressqueda obsoleta. Si usas la LTS: escribereportProgress: true, que activa los dos progresos a la vez con el backend XHR por defecto.
Antes una sola opción, reportProgress, valía para las dos direcciones. Ahora hay dos, porque subir y bajar no se comportan igual. Medimos cada combinación en Chromium, con una subida de 20 MiB y una descarga de 5 MB:
| Opción | Backend | Resultado |
|---|---|---|
reportDownloadProgress | fetch | Unos 100 eventos DownloadProgress |
reportDownloadProgress | XHR | Unos 60 eventos DownloadProgress |
reportUploadProgress | XHR | Eventos UploadProgress, hasta 20 971 520 de 20 971 520 |
reportUploadProgress | fetch | Falla al instante: status: 0 y, en err.error, NG02824 |
reportProgress (obsoleta), subiendo | fetch | Sin un solo UploadProgress, ni aviso alguno |
Las dos últimas filas son la clave. La API fetch no informa de lo que sube, y por eso la opción nueva falla de forma ruidosa. Es preferible a lo que hace la antigua, con la que tu barra de progreso se queda en cero sin explicación.
Subir con progreso exige XHR 🔄 v22.0
🔄 Cambia en Angular 22.0. Como el defecto pasa a ser
fetch, para ver el progreso de una subida hay que pedir XHR de forma explícita. Si usas la LTS: no añadasprovidersa la ruta, porque el defecto ya es XHR; deja la ruta solo con suloadComponent.
El mensaje de NG02824 lo dice claro: The FetchBackend does not support upload progress reporting. Please use withXhr(). Tienes dos opciones. La primera es añadir withXhr() en app.config.ts, y entonces toda la aplicación usa XHR. La segunda, más fina, es crear un cliente solo para la parte de la aplicación que sube archivos, con los providers de una ruta:
// src/app/app.routes.ts
import { provideHttpClient, withXhr } from '@angular/common/http';
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', redirectTo: 'empleados', pathMatch: 'full' },
{
path: 'empleados',
loadComponent: () =>
import('./paginas/lista-empleados').then((m) => m.ListaEmpleados),
},
{
path: 'gestion',
loadComponent: () =>
import('./paginas/gestion-empleados').then((m) => m.GestionEmpleados),
},
{
path: 'transferencias',
// Un HttpClient propio solo para esta rama: el resto sigue con fetch
providers: [provideHttpClient(withXhr())],
loadComponent: () =>
import('./archivos/transferencias').then((m) => m.Transferencias),
},
];
Esa rama recibe su propio HttpClient, y el resto de la aplicación sigue con fetch. El componente raíz solo aporta la navegación entre las pantallas del módulo:
// src/app/app.ts
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterLink, RouterLinkActive, RouterOutlet],
template: `
<header>
<strong>EMPRESA</strong>
<nav>
<a routerLink="/empleados" routerLinkActive="activo">Empleados</a>
<a routerLink="/gestion" routerLinkActive="activo">Gestión</a>
<a routerLink="/transferencias" routerLinkActive="activo">Transferencias</a>
</nav>
</header>
<main><router-outlet /></main>
`,
styleUrl: './app.css',
})
export class App {}
⚠️ Importante: Ese cliente nuevo no hereda los interceptores de
app.config.ts: en nuestra prueba, las peticiones de esta pantalla no dejaron rastro del interceptor de registro. Si los necesitas ahí, repítelos:provideHttpClient(withXhr(), withInterceptors([...])).withRequestsMadeViaParent()no te sirve, porque Angular rechaza combinarla conwithXhr()en la misma llamada.
La pantalla de transferencias
Esta es la pantalla completa, con su barra de subida, su barra de descarga y la cancelación:
// src/app/archivos/url-archivos.ts
import { InjectionToken } from '@angular/core';
export const URL_ARCHIVOS = new InjectionToken<string>('URL_ARCHIVOS', {
factory: () => 'http://localhost:3001',
});
// src/app/archivos/transferencias.ts
import { Component, inject, signal } from '@angular/core';
import { HttpClient, HttpEventType } from '@angular/common/http';
import { Subscription } from 'rxjs';
import { URL_ARCHIVOS } from './url-archivos';
@Component({
selector: 'app-transferencias',
template: `
<h2>Transferencias</h2>
<h3>Subir un archivo</h3>
<input #selector type="file" aria-label="Archivo" (change)="subir(selector.files)" />
<progress max="100" [value]="subida()"></progress>
<span class="dato">{{ subida() }} %</span>
<button type="button" [disabled]="!enCurso()" (click)="cancelar()">Cancelar</button>
<p role="status">{{ estado() }}</p>
<h3>Descargar el informe</h3>
<button type="button" (click)="descargar()">Descargar</button>
<progress max="100" [value]="descarga()"></progress>
<span class="dato">{{ descarga() }} %</span>
<p>{{ resumen() }}</p>
`,
})
export class Transferencias {
private readonly http = inject(HttpClient);
private readonly url = inject(URL_ARCHIVOS);
private subida$?: Subscription;
protected readonly enCurso = signal(false);
protected readonly subida = signal(0);
protected readonly descarga = signal(0);
protected readonly estado = signal('Elige un archivo');
protected readonly resumen = signal('');
protected subir(archivos: FileList | null) {
const archivo = archivos?.[0];
if (!archivo) return;
const datos = new FormData();
datos.append('archivo', archivo);
this.subida.set(0);
this.enCurso.set(true);
this.estado.set(`Subiendo ${archivo.name}...`);
this.subida$ = this.http
.post<{ bytes: number }>(`${this.url}/subir`, datos, {
observe: 'events',
reportUploadProgress: true,
})
.subscribe({
next: (evento) => {
if (evento.type === HttpEventType.UploadProgress && evento.total) {
this.subida.set(Math.round((100 * evento.loaded) / evento.total));
} else if (evento.type === HttpEventType.Response) {
this.estado.set(`Recibidos ${evento.body?.bytes} bytes`);
}
},
error: () => {
this.estado.set('La subida ha fallado');
this.enCurso.set(false);
},
complete: () => this.enCurso.set(false),
});
}
protected cancelar() {
this.subida$?.unsubscribe(); // aborta la petición en curso
this.enCurso.set(false);
this.estado.set('Subida cancelada');
}
protected descargar() {
this.descarga.set(0);
this.http
.get(`${this.url}/informe`, {
responseType: 'blob',
observe: 'events',
reportDownloadProgress: true,
})
.subscribe((evento) => {
if (evento.type === HttpEventType.DownloadProgress && evento.total) {
this.descarga.set(Math.round((100 * evento.loaded) / evento.total));
} else if (evento.type === HttpEventType.Response) {
this.resumen.set(`Informe de ${evento.body?.size} bytes listo`);
}
});
}
}

Figura 9. Captura real tomada a mitad de la descarga. La subida ya ha terminado.
Tres detalles:
evento.totalpuede faltar. Si el servidor no envíaContent-Length, no se puede calcular un porcentaje. En ese caso, muestra los bytes deloadedo una barra indeterminada.- El tipo se estrecha solo. Tras comprobar
evento.type, TypeScript sabe qué propiedades tiene el evento. FormDataes el formato estándar para enviar archivos, y el navegador pone la cabecera correcta.
13. Reintentos, tiempos y cancelación
Reintentar, pero no cualquier cosa
RxJS ofrece retry, que vuelve a suscribirse al fallar. Como el Observable de HttpClient es frío, cada suscripción es una petición nueva. La versión simple es tentadora, y tiene un problema:
this.http.get(`${url}/empleados/9999`).pipe(retry(2));
GET .../empleados/9999 -> error 404 (13 ms)
GET .../empleados/9999 -> error 404 (13 ms)
GET .../empleados/9999 -> error 404 (10 ms)
Tres peticiones para descubrir tres veces que el empleado no existe. Tiene sentido reintentar solo lo que puede arreglarse solo: un corte de red, un servidor saturado. Y hay que esperar entre intentos, porque insistir de inmediato sobre un servidor saturado empeora el problema. Por eso la espera crece en cada intento, lo que se llama backoff exponencial.
retry admite una función delay que decide en cada fallo: devuelve un Observable que marca cuándo reintentar, o uno de error para desistir. Con eso se escribe un operador propio:
// src/app/reintentos/reintentar.ts
import { HttpErrorResponse } from '@angular/common/http';
import { MonoTypeOperatorFunction, retry, throwError, timer } from 'rxjs';
// Solo tiene sentido reintentar lo que puede arreglarse solo
export function esTransitorio(err: unknown): boolean {
return (
err instanceof HttpErrorResponse &&
[0, 429, 502, 503, 504].includes(err.status)
);
}
// Espera 300 ms, 600 ms, 1200 ms... entre intentos
export function reintentar<T>(maximo = 3, baseMs = 300): MonoTypeOperatorFunction<T> {
return retry({
count: maximo,
delay: (err, intento) =>
esTransitorio(err) ? timer(baseMs * 2 ** (intento - 1)) : throwError(() => err),
});
}
this.http.get(`${url}/categorias`).pipe(reintentar());
Para ensayarlo sin estropear la API, este interceptor simula un servidor con problemas:
// src/app/reintentos/caos.ts
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { defer, throwError } from 'rxjs';
// Solo para practicar: las primeras peticiones fallan con un 503
export function caosInterceptor(fallos: number): HttpInterceptorFn {
let pendientes = fallos;
return (req, next) =>
// defer decide en cada suscripción, igual que una petición real
defer(() => {
if (pendientes > 0) {
pendientes--;
const error = new HttpErrorResponse({
status: 503, statusText: 'Service Unavailable', url: req.url,
});
return throwError(() => error);
}
return next(req);
});
}
Figura 10. Con dos fallos simulados, la tercera petición funciona. Tiempos reales del navegador.
Los resultados de las pruebas:
| Caso | Intentos | Resultado |
|---|---|---|
| Dos 503 y luego bien | 3 | Éxito, con 11 categorías |
Siempre 503, reintentar(2) | 3 | Error 503 |
| 404 | 1 | Error 404, sin reintentos |
⚠️ Importante: Fíjate en el
deferdecaosInterceptor. Sin él, la decisión se toma una sola vez, y todos los reintentos ven el mismo error. Es fácil caer en esto: dentro de un interceptor,retryse suscribe de nuevo al mismoObservable, sin volver a ejecutar el interceptor que tiene detrás. Con peticiones reales no pasa, porque el backend sí hace el trabajo en cada suscripción.
¿Qué se puede repetir?
Una operación es idempotente si repetirla deja el servidor igual que hacerla una vez:
| Método | Idempotente | ¿Reintentar sin más? |
|---|---|---|
| GET | Sí | Sí |
| PUT | Sí | Normalmente sí |
| DELETE | Sí | Normalmente sí, aunque el segundo puede dar 404 |
| POST | No | No: un POST repetido crea dos pedidos |
| PATCH | Depende | Solo si lo sabes seguro |
Si se corta la red durante un POST, no sabes si llegó al servidor. La solución sólida es que el servidor admita una clave única por operación. Sin eso, no lo repitas por tu cuenta y deja que el usuario decida.
Tiempo máximo: timeout
La opción timeout aborta la petición si tarda más de los milisegundos indicados:
this.http.get(`${url}/lenta`, { timeout: 200 });
El error llega con status: 0, igual que un corte de red. Para distinguirlo, mira err.error.name, que vale TimeoutError. En nuestra prueba, con una respuesta retenida 700 ms, la petición falló a los 214 ms. mensajeDeError ya contempla este caso. reintentar lo trata como pasajero, puesto que tiene status 0: es razonable en una lectura, y por eso hay que evitarlo en un POST.
Cancelar
Cancelar la suscripción aborta la petición de verdad. En la red aparece como net::ERR_ABORTED, tanto con fetch como con XHR:
const suscripcion = this.http.get(`${url}/lenta`).subscribe();
suscripcion.unsubscribe(); // la petición se aborta
Es lo que hace takeUntilDestroyed al destruir el componente. El caso clásico es un buscador: cada tecla debería cancelar la búsqueda anterior, y para eso existe switchMap, que estudiarás en el módulo 14 y aplicarás en el módulo 15.
14. JSONP, obsoleto 🔄 v22.1
🔄 Cambia en Angular 22.1.
withJsonpSupport,HttpClient.jsonp,JsonpInterceptoryJsonpClientBackendpasan a estar obsoletos. En la LTS (Angular 21) no lo están. Si usas la LTS: tampoco lo uses en código nuevo; sustitúyelo por peticiones normales a un servidor con CORS o por un proxy propio.
JSONP es una técnica de la época anterior a CORS. Una etiqueta <script> sí podía cargar código de otro dominio, así que el servidor devolvía JavaScript en lugar de JSON: una llamada a una función con los datos dentro.
GET https://otro.dominio/datos?callback=ng_jsonp_callback_0
ng_jsonp_callback_0({"hora":"ok"})
Lo reconocerás en código antiguo así, y aún funciona:
provideHttpClient(withJsonpSupport())
this.http.jsonp(`${url}/jsonp`, 'callback').subscribe(...);
Solo admite GET, y solo URL absolutas http o https. Pero el problema es otro: ejecutas el código que te envíe un servidor ajeno, con todos los permisos de tu página. Si ese servidor se ve comprometido, tu aplicación también. En Angular 22 la consola te lo recuerda con este aviso real:
JSONP support is deprecated as it can cause XSS vulnerabilities, and will be removed
in a future version of Angular. Please use standard HTTP requests instead.
La alternativa es la que ya conoces: una petición normal si el servidor admite CORS, o una ruta en tu propio servidor que haga de intermediaria si no lo admite.
15. Depuración y errores frecuentes
Cuando una petición no va como esperabas, sigue este orden:
- Abre la pestaña Red (F12) y filtra por Fetch/XHR. ¿Ha salido la petición? Si no aparece, nadie se ha suscrito.
- Mira la petición: método, URL final con sus parámetros y cabeceras. ¿Lleva
Authorization? - Mira la respuesta: código y cuerpo. Compárala con tu interfaz.
- Lee la consola. Los errores de CORS aparecen ahí explicados, aunque tu código solo vea
status: 0. - Activa un interceptor de registro como el de la sección 11 para ver el recorrido completo.
| Síntoma | Causa probable | Solución |
|---|---|---|
| No aparece la petición en Red | Falta subscribe | Suscríbete, o usa \| async |
| La petición sale dos veces | Dos suscripciones, por ejemplo dos \| async | Guarda el resultado en una señal |
| Los datos llegan, pero la pantalla no cambia | Propiedad corriente en lugar de una señal | Usa signal |
status: 0 con el servidor en marcha | CORS, o la cabecera no está permitida | Lee la consola; arréglalo en el servidor |
status: 0 y ERR_CONNECTION_REFUSED | El servidor está parado o el puerto es otro | Arranca la API y revisa la URL |
Error con status: 200 | La respuesta no es JSON, a menudo HTML | Revisa la URL o usa responseType |
@for falla porque no es iterable | La API devuelve Pagina<T>, no un array | Recorre pagina.contenido |
undefined en los datos | El tipo no coincide con la respuesta | Compara con la pestaña Red |
401 con sesión iniciada | Token caducado, o el interceptor no se ha registrado | Revisa withInterceptors y la cabecera |
| El parámetro no llega | params.set sin guardar el resultado | params = params.set(...) |
| La cabecera no llega | headers.set sin guardar el resultado | Igual: son inmutables |
NG0203 en un interceptor | inject dentro de un operador | Llama a inject al principio |
| Un interceptor no actúa en una ruta | Esa ruta tiene su propio provideHttpClient | Repite ahí withInterceptors |
NG02824 | Progreso de subida con fetch | Usa withXhr() |
| La barra de subida no se mueve | reportProgress obsoleta con fetch | reportUploadProgress y withXhr() |
Dos temas se tratan en otros módulos. La protección XSRF ya viene activada: HttpClient lee la cookie XSRF-TOKEN y envía su valor en la cabecera X-XSRF-TOKEN en las escrituras al propio origen; el módulo 27 la explica junto a la seguridad. Y las pruebas con provideHttpClientTesting, que permiten simular respuestas sin red, están en el módulo 26.
16. Ejercicios
Arranca la API EMPRESA antes de empezar. Todas las soluciones se han compilado y ejecutado contra ella. Si usas la LTS, recuerda cambiar @Service() como se indica en la sección 5.
Ejercicio 1: Lista de departamentos
Crea un servicio DepartamentosApi con un método listar() que devuelva Observable<Departamento[]>, ordenado por nombre. Quien llama debe recibir el array, no el objeto de paginación. Muéstralo en un componente con un mensaje de carga y otro de error.
Ver solución
// src/app/ejercicios/departamentos.ts
import { HttpClient } from '@angular/common/http';
import { Component, inject, Service, signal } from '@angular/core';
import { map, Observable } from 'rxjs';
import { API_URL } from '../empresa/api-url';
import { mensajeDeError } from '../empresa/errores';
import { Departamento, Pagina } from '../empresa/modelos';
@Service()
export class DepartamentosApi {
private readonly http = inject(HttpClient);
private readonly url = `${inject(API_URL)}/departamentos`;
// Quien llama quiere el array, no el sobre de la paginación
listar(): Observable<Departamento[]> {
return this.http
.get<Pagina<Departamento>>(this.url, { params: { orden: 'nombre' } })
.pipe(map((pagina) => pagina.contenido));
}
}
@Component({
selector: 'app-lista-departamentos',
template: `
<h2>Departamentos</h2>
@if (error(); as mensaje) {
<p class="fallo" role="alert">{{ mensaje }}</p>
}
<ul>
@for (d of departamentos(); track d.deptId) {
<li>{{ d.nombre }} ({{ d.localidad }})</li>
} @empty {
@if (!error()) {
<li>Cargando...</li>
}
}
</ul>
`,
})
export class ListaDepartamentos {
protected readonly departamentos = signal<Departamento[]>([]);
protected readonly error = signal<string | null>(null);
constructor() {
inject(DepartamentosApi).listar().subscribe({
next: (lista) => this.departamentos.set(lista),
error: (err) => this.error.set(mensajeDeError(err)),
});
}
}
map saca el array del sobre, y así los componentes no dependen de cómo pagina la API. Salida en pantalla:
CONTABILIDAD (Sevilla)
DIRECCIÓN (Madrid)
INFORMÁTICA (Valencia)
INVESTIGACIÓN (Bilbao)
LOGÍSTICA (Zaragoza)
MARKETING (Barcelona)
VENTAS (Madrid)
Ejercicio 2: Detalle con 404
Escribe un componente con input.required<number>() que muestre un empleado. Si no existe, debe enseñar un aviso distinto al de un error general. Si el id cambia mientras se carga, la petición anterior debe cancelarse.
Ver solución
// src/app/ejercicios/detalle-empleado.ts
import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http';
import { Component, computed, DestroyRef, effect, inject, input, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subscription } from 'rxjs';
import { EmpleadosApi } from '../empresa/empleados-api';
import { mensajeDeError } from '../empresa/errores';
import { Empleado } from '../empresa/modelos';
@Component({
selector: 'app-detalle-empleado',
template: `
@if (noExiste()) {
<p class="aviso">No hay ningún empleado con el id {{ id() }}.</p>
} @else if (fallo(); as mensaje) {
<p class="fallo" role="alert">{{ mensaje }}</p>
} @else if (empleado(); as e) {
<h2>{{ e.nombre }} {{ e.apellidos }}</h2>
<p>{{ e.oficio }}, alta el {{ e.fechaAlta }}</p>
} @else {
<p>Cargando...</p>
}
`,
})
export class DetalleEmpleado {
readonly id = input.required<number>();
private readonly api = inject(EmpleadosApi);
private readonly destroyRef = inject(DestroyRef);
private peticion?: Subscription;
protected readonly empleado = signal<Empleado | null>(null);
private readonly error = signal<unknown>(null);
protected readonly noExiste = computed(() => {
const err = this.error();
return err instanceof HttpErrorResponse && err.status === HttpStatusCode.NotFound;
});
protected readonly fallo = computed(() =>
this.error() ? mensajeDeError(this.error()) : null,
);
constructor() {
effect(() => {
const id = this.id();
this.peticion?.unsubscribe(); // si cambia el id, la anterior ya no interesa
this.empleado.set(null);
this.error.set(null);
this.peticion = this.api
.obtener(id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (e) => this.empleado.set(e),
error: (err) => this.error.set(err),
});
});
}
}
Guardar el error crudo en la señal permite derivar con computed tanto noExiste como el mensaje general. Sin unsubscribe, una respuesta lenta del id anterior podría llegar la última y pisar la buena. Con id 100 muestra Ana García López y DIRECTOR GENERAL, alta el 2010-01-15. Con 9999, No hay ningún empleado con el id 9999.
Ejercicio 3: Paginación y orden
Muestra los empleados de cinco en cinco, con botones Anterior y Siguiente y otro que alterne el orden por salario. Al cambiar el orden, vuelve a la primera página. Desactiva los botones cuando no tengan sentido.
Ver solución
// src/app/ejercicios/empleados-paginados.ts
import { Component, DestroyRef, effect, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subscription } from 'rxjs';
import { EmpleadosApi } from '../empresa/empleados-api';
import { Empleado, Pagina } from '../empresa/modelos';
@Component({
selector: 'app-empleados-paginados',
template: `
<button type="button" (click)="alternarOrden()">
Salario {{ orden() === 'salario' ? 'ascendente' : 'descendente' }}
</button>
<ol>
@for (e of resultado()?.contenido; track e.empId) {
<li>{{ e.apellidos }}: {{ e.salario }} €</li>
}
</ol>
<button type="button" [disabled]="pagina() === 1" (click)="mover(-1)">Anterior</button>
<span>Página {{ pagina() }} de {{ resultado()?.paginas }}</span>
<button type="button" [disabled]="pagina() === resultado()?.paginas" (click)="mover(1)">
Siguiente
</button>
`,
})
export class EmpleadosPaginados {
private readonly api = inject(EmpleadosApi);
private readonly destroyRef = inject(DestroyRef);
private peticion?: Subscription;
protected readonly pagina = signal(1);
protected readonly orden = signal<'salario' | '-salario'>('-salario');
protected readonly resultado = signal<Pagina<Empleado> | null>(null);
constructor() {
effect(() => {
const filtro = { pagina: this.pagina(), orden: this.orden(), tamano: 5 };
this.peticion?.unsubscribe();
this.peticion = this.api
.listar(filtro)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((pagina) => this.resultado.set(pagina));
});
}
protected mover(delta: number) {
this.pagina.update((p) => p + delta);
}
protected alternarOrden() {
this.orden.update((o) => (o === 'salario' ? '-salario' : 'salario'));
this.pagina.set(1);
}
}
effect lee las dos señales del filtro, así que repite la petición al cambiar cualquiera de ellas. Las dos escrituras de alternarOrden se agrupan en una sola ejecución del efecto. El orden descendente muestra en la primera página García López: 6500 €, y el ascendente Reyes Fuentes: 900 €.
Ejercicio 4: Interceptor de idioma
Escribe un interceptor que añada Accept-Language: es-ES a todas las peticiones, salvo a las que ya traigan esa cabecera. ¿Por qué esta cabecera no da problemas de CORS, y X-Idioma sí?
Ver solución
// src/app/ejercicios/idioma.ts
import { HttpInterceptorFn } from '@angular/common/http';
export const idiomaInterceptor: HttpInterceptorFn = (req, next) => {
if (req.headers.has('Accept-Language')) {
return next(req); // quien llama manda
}
return next(req.clone({ setHeaders: { 'Accept-Language': 'es-ES' } }));
};
Accept-Language es una de las cabeceras que el navegador considera seguras: no exige petición previa. En la prueba salió con lang=es-ES y respuesta correcta. X-Idioma dispararía el OPTIONS y, como la API solo permite Content-Type y Authorization, acabaría en status: 0.
Ejercicio 5: Indicador de carga global
Crea un servicio con una señal visible que indique si hay alguna petición en curso, y un interceptor que la mantenga. Debe funcionar con varias peticiones simultáneas, con errores y con cancelaciones.
Ver solución
// src/app/ejercicios/carga.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { computed, inject, Service, signal } from '@angular/core';
import { finalize } from 'rxjs';
@Service()
export class IndicadorCarga {
private readonly activas = signal(0);
readonly visible = computed(() => this.activas() > 0);
empezar() {
this.activas.update((n) => n + 1);
}
terminar() {
this.activas.update((n) => n - 1);
}
}
export const cargaInterceptor: HttpInterceptorFn = (req, next) => {
const indicador = inject(IndicadorCarga);
indicador.empezar();
// finalize cubre los tres finales: éxito, error y cancelación
return next(req).pipe(finalize(() => indicador.terminar()));
};
Un booleano fallaría con dos peticiones: la primera en terminar lo apagaría. Un contador no. finalize garantiza la resta en todos los finales: en la prueba, visible fue true durante la petición y false después, también tras un 404. En la plantilla basta con @if (indicador.visible()) { ... }.
Ejercicio 6: Caché por petición
Escribe un interceptor que guarde las respuestas GET durante un tiempo, solo en las peticiones que lo pidan mediante HttpContext. Si hay una respuesta vigente, devuélvela sin ir a la red.
Ver solución
// src/app/ejercicios/cache.ts
import { HttpContextToken, HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { of, tap } from 'rxjs';
export const CACHE_MS = new HttpContextToken<number>(() => 0);
const guardadas = new Map<string, { respuesta: HttpResponse<unknown>; caduca: number }>();
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
const vida = req.context.get(CACHE_MS);
if (req.method !== 'GET' || vida === 0) {
return next(req);
}
const clave = req.urlWithParams;
const entrada = guardadas.get(clave);
if (entrada && entrada.caduca > Date.now()) {
return of(entrada.respuesta.clone());
}
return next(req).pipe(
tap((evento) => {
if (evento instanceof HttpResponse) {
guardadas.set(clave, { respuesta: evento, caduca: Date.now() + vida });
}
}),
);
};
Se usa así:
this.http.get(url, { context: new HttpContext().set(CACHE_MS, 30_000) });
Es el tercer uso de un interceptor: responder sin llamar a next. of(...) crea un Observable con la respuesta guardada. En la prueba hicimos tres llamadas, dos con caché y una sin ella, y hubo dos peticiones reales. req.urlWithParams es la clave correcta porque incluye los parámetros. Una caché real debería vaciarse tras una escritura: piensa qué pasaría si modificas un dato y luego lees la versión guardada.
Ejercicio 7: Reintentos globales
Convierte reintentar en un interceptor que solo actúe en peticiones GET. Pruébalo poniendo caosInterceptor(2) detrás.
Ver solución
// src/app/ejercicios/reintentos-interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { reintentar } from '../reintentos/reintentar';
export const reintentosInterceptor: HttpInterceptorFn = (req, next) =>
req.method === 'GET' ? next(req).pipe(reintentar(3, 100)) : next(req);
provideHttpClient(withInterceptors([reintentosInterceptor, caosInterceptor(2)]))
Con ese orden, la petición devuelve sus 11 categorías tras dos fallos. Solo se reintenta GET, ya que el resto no es seguro repetirlo. El orden importa: si inviertes el array, caosInterceptor queda por fuera, los reintentos no lo atraviesan y el error sube directamente.
Ejercicio 8 (propuesto): Exportar a CSV
Añade un botón que pida todos los empleados, los convierta en un texto CSV y lo descargue como archivo. Pista: la API admite hasta 100 elementos por página, y puedes crear el enlace con URL.createObjectURL. Libera después la URL con revokeObjectURL.
Ejercicio 9 (propuesto): Panel de mando
Construye un panel que combine en una pantalla GET /api/estadisticas y los cinco empleados con mayor salario. Usa forkJoin de RxJS, que espera a varias peticiones, para mostrar el panel cuando lleguen las dos. ¿Qué ocurre con la pantalla si solo falla una?
Ejercicio 10 (propuesto): Borrar con confirmación
En la pantalla de gestión, pide confirmación antes de borrar. Si la API responde 409, muestra una explicación y un enlace a los subordinados de esa persona. Si responde 403, oculta en adelante los botones de borrar para ese usuario, usando el rol de Auth.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Cliente | HttpClient, provideHttpClient | Funciona sin configurar; provideHttpClient es donde se configura |
| Backend | fetch por defecto 🔄 v22.0, withXhr 🆕 v22.0 | XHR solo si necesitas progreso de subida |
| Tipos | get<T>() | Es una promesa al compilador, no una validación |
| Ejecución | subscribe | Sin suscripción no hay petición; con dos, hay dos |
| Estado en pantalla | signal | Carga, error y datos, siempre en señales |
| Respuesta completa | observe, responseType | 'response' da cabeceras; 'events', progreso |
| Parámetros | HttpParams, set, append | Inmutables y codificados; nunca concatenes |
| Cabeceras | HttpHeaders | Inmutables; una cabecera propia puede activar CORS |
| Errores | HttpErrorResponse, status, error | status 0 significa que no hay respuesta legible |
| Escrituras | post, put, delete<void> | Actualiza el estado local con lo que devuelve la API |
| Interceptores | HttpInterceptorFn, withInterceptors | Orden de ida, inverso a la vuelta; inject al principio |
| Modificar peticiones | req.clone({ setHeaders }) | HttpRequest es inmutable |
| Datos para interceptores | HttpContextToken, HttpContext | No viajan a la red |
| Progreso | reportUploadProgress, reportDownloadProgress 🆕 v22.0 | La subida exige XHR |
| Reintentos | retry({ count, delay }) | Solo fallos pasajeros, con espera creciente, y nunca a ciegas en POST |
| Tiempo máximo | timeout | Da status 0 y err.error.name === 'TimeoutError' |
| Cancelar | unsubscribe, takeUntilDestroyed | Aborta de verdad la petición |
| JSONP | withJsonpSupport 🔄 v22.1 | Obsoleto por riesgo de XSS; usa CORS o un intermediario |