Patrón Decorator en pipelines HTTP
Usa el patrón Decorator para componer aspectos transversales como logging, métricas y reintentos en pipelines de peticiones HTTP sin modificar la lógica central
El patrón Decorator envuelve un objeto para añadir responsabilidades dinámicamente. Aplicado a clientes HTTP, compone aspectos transversales (logging, reintentos, métricas, autenticación) alrededor de la lógica central de la petición en lugar de contaminarla.
Cuándo Usar Esto
- Varios aspectos transversales deben envolver cada llamada a la API
- Quieres añadir o quitar un aspecto sin tocar los demás
- La lógica central de la petición debe mantenerse testeable y enfocada
No uses este patrón cuando solo existe un aspecto: una función wrapper es más simple que un stack de decoradores de uno. Si los aspectos dependen de pares petición/respuesta que fluyen por etapas, usa un pipeline de middleware.
Problema
Añadir logging, reintentos, métricas y autenticación a cada llamada HTTP produce clases de cliente monolíticas o boilerplate copiado en cada punto de llamada.
Solución
// api/HttpClient.ts
interface HttpClient {
request(url: string, options: RequestInit): Promise<Response>;
}
// api/FetchClient.ts
class FetchClient implements HttpClient {
async request(url: string, options: RequestInit): Promise<Response> {
return fetch(url, options);
}
}
// decorators/BaseClientDecorator.ts
abstract class BaseClientDecorator implements HttpClient {
constructor(protected client: HttpClient) {}
abstract request(url: string, options: RequestInit): Promise<Response>;
}
// decorators/LoggingDecorator.ts
class LoggingDecorator extends BaseClientDecorator {
async request(url: string, options: RequestInit): Promise<Response> {
const start = performance.now();
try {
const response = await this.client.request(url, options);
console.log(`${options.method || 'GET'} ${url} → ${response.status} (${(performance.now() - start).toFixed(0)}ms)`);
return response;
} catch (error) {
console.error(`${options.method || 'GET'} ${url} → ERROR`);
throw error;
}
}
}
// decorators/RetryDecorator.ts
class RetryDecorator extends BaseClientDecorator {
private static readonly retryableStatuses = new Set([502, 503, 504]);
constructor(
client: HttpClient,
private maxRetries: number = 3,
private baseDelayMs: number = 500
) {
super(client);
}
async request(url: string, options: RequestInit): Promise<Response> {
let lastError: unknown;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
const response = await this.client.request(url, options);
// fetch solo lanza en errores de red; los errores HTTP hay que comprobarlos
if (response.ok || !RetryDecorator.retryableStatuses.has(response.status)) {
return response;
}
lastError = new Error(`HTTP ${response.status} from ${url}`);
} catch (error) {
lastError = error; // error de red: reintentable
}
if (attempt < this.maxRetries) {
// backoff exponencial con jitter evita reintentos sincronizados
const delay = this.baseDelayMs * 2 ** attempt + Math.random() * 100;
await new Promise(r => setTimeout(r, delay));
}
}
throw lastError;
}
}
// decorators/AuthDecorator.ts
class AuthDecorator extends BaseClientDecorator {
constructor(client: HttpClient, private token: string) {
super(client);
}
async request(url: string, options: RequestInit): Promise<Response> {
const headers = new Headers(options.headers);
headers.set('Authorization', `Bearer ${this.token}`);
return this.client.request(url, { ...options, headers });
}
}
El detalle que la mayoría de ejemplos pasan por alto: fetch no lanza excepción ante errores HTTP. Un decorador de reintentos que solo captura excepciones devolverá un 503 al llamador tan tranquilo. Comprueba response.ok dentro del decorador y reintenta solo ante estados transitorios.
Uso
const client = new AuthDecorator(
new RetryDecorator(
new LoggingDecorator(new FetchClient()),
3
),
process.env.API_TOKEN!
);
El decorador más externo (Auth) corre primero, añadiendo el header del token. Luego Retry captura fallos y reintenta. Después Logging registra tiempos. Finalmente FetchClient realiza la llamada de red. Cada decorador envuelve al siguiente, formando un stack.
Cómo Funciona
Cada decorador implementa la misma interfaz HttpClient y mantiene una referencia al siguiente decorador de la cadena. Cuando request() se ejecuta, el decorador puede modificar los inputs, llamar al cliente interno y después modificar o inspeccionar el output. Como todas las capas comparten la interfaz, se componen de forma transparente: el llamador nunca sabe cuántas capas existen.
El orden de decoración importa. Auth debe envolver a Retry para que los reintentos lleven el token. Logging debe ir en la capa más interna para medir el tiempo real de red y no la duración total de los reintentos. Metrics debe ir en la más externa para capturar la latencia de extremo a extremo.
Dos casos límite que conviene conocer de entrada:
- Respuestas en streaming: un decorador que llama a
response.json()oresponse.text()consume el body. Usaresponse.clone()si necesitas inspeccionar el payload, o deja pasar las respuestas en streaming sin tocarlas. - Cancelación de peticiones: un decorador externo de Timeout crea un
AbortController, pasasignaldentro deoptionsy llama aabort()al llegar al deadline. Elfetchinterno entonces rechaza conAbortError, que los decoradores externos deben tratar como error no reintentable.
Decorator frente a alternativas
| Enfoque | Cómo compone | Acoplamiento al framework | Cuándo elegirlo |
|---|---|---|---|
| Decorator | Envuelve una interfaz compartida, a cualquier profundidad | Ninguno — TypeScript plano | Aspectos de cliente (auth, retry, logging) sobre un cliente |
| Pipeline de middleware | Etapas secuenciales sobre pares req/res | Express, Koa, frameworks de servidor | Manejo de peticiones del lado servidor |
| Proxy | Envuelve el target y controla el acceso | Ninguno | Lazy loading, control de acceso, caché — sin añadir comportamiento |
| Interceptors | Hooks del framework (Axios, Angular) | Alto | Cuando ya estás dentro de la capa HTTP de ese framework |
| Chain of Responsibility | Cada handler decide si procesa o reenvía | Ninguno | Handlers que pueden cortar la cadena antes (validación, authz) |
Mejores Prácticas
- Mantén cada decorador en un solo aspecto. Un decorador que loggea y reintenta son dos decoradores disfrazados.
- Clona el objeto
optionsantes de mutar los headers: mutar el original filtra estado entre reintentos. - Re-lanza los errores salvo que el decorador exista para manejarlos (como Retry). Tragar errores rompe el contrato de la cadena.
- Mantén los decoradores stateless siempre que sea posible; el contador de fallos del circuit breaker es la excepción, no la regla.
- Usa una función factory para construir el stack. El nesting en línea se vuelve ilegible pasadas cuatro capas:
function createHttpClient(config: {
token: string;
maxRetries?: number;
timeoutMs?: number;
}): HttpClient {
let client: HttpClient = new FetchClient();
client = new LoggingDecorator(client);
client = new RetryDecorator(client, config.maxRetries ?? 3);
client = new AuthDecorator(client, config.token);
return client;
}
- Testea cada decorador de forma aislada pasando un mock del cliente interno: verifica que llama a través y transforma los inputs correctamente.
Ejemplo de Circuit Breaker
Para llamadas en producción a downstreams poco fiables, añade un circuit breaker. Tras threshold fallos consecutivos se abre y rechaza peticiones durante resetTimeout milisegundos; después deja pasar una sola petición de prueba (half-open): si tiene éxito el breaker se cierra, si falla el temporizador se reinicia.
class CircuitBreakerDecorator extends BaseClientDecorator {
private failures = 0;
private isOpen = false;
private halfOpen = false;
private openedAt = 0;
constructor(
client: HttpClient,
private threshold: number = 5,
private resetTimeout: number = 30000
) {
super(client);
}
async request(url: string, options: RequestInit): Promise<Response> {
if (this.isOpen) {
if (Date.now() - this.openedAt > this.resetTimeout) {
this.isOpen = false;
this.halfOpen = true; // permite una petición de prueba
} else {
throw new Error(`Circuit breaker abierto para ${url}`);
}
}
try {
const response = await this.client.request(url, options);
if (response.ok) {
this.failures = 0;
this.halfOpen = false;
} else {
this.recordFailure();
}
return response;
} catch (error) {
this.recordFailure();
throw error;
}
}
private recordFailure(): void {
this.failures++;
if (this.halfOpen || this.failures >= this.threshold) {
this.isOpen = true;
this.halfOpen = false;
this.openedAt = Date.now();
}
}
}
Variaciones
- Decorador condicional: aplica lógica solo para URLs o métodos HTTP específicos
- Decorador de métricas: envía tiempos y distribuciones de códigos de estado a Prometheus
- Decorador de caché: combina con el patrón Proxy para cachear respuestas GET
- Decorador de timeout: aborta peticiones que superan un deadline con
AbortController - Decorador de rate limit: limita peticiones concurrentes o por segundo
- Decorador de tracing: inyecta trace IDs y emite spans a OpenTelemetry
- Decorador de idempotencia: genera una clave de idempotencia como header para que los reintentos de POST/PUT sean seguros
Errores Comunes
- Mutar el objeto de petición en lugar de clonarlo: los reintentos y decoradores hermanos ven el estado mutado.
- Tragar errores: un decorador que captura pero no re-lanza oculta fallos al llamador.
- Reintentar todo: las respuestas 4xx y los POST no idempotentes no deben reintentarse.
- Orden incorrecto: logging fuera de retry registra el tiempo total incluyendo los delays de backoff, lo que distorsiona las métricas de latencia.
- Demasiadas capas: pasados 5-6 decoradores el stack se vuelve difícil de trazar; agrupa aspectos relacionados.
- Lógica de negocio en decoradores: manejan aspectos de transporte, no reglas de dominio.
- Leer el body sin clonar:
response.json()consume el stream; usaresponse.clone()primero. - Configuración hardcodeada: pasa reintentos, timeouts y tokens por el constructor para que los tests puedan variarlos.
Solución de Problemas
- RetryDecorator devuelve errores en lugar de reintentar:
fetchresuelve ante errores HTTP — compruebaresponse.ok; no dependas delcatch. - El body de la respuesta ya fue consumido: un decorador leyó
response.json()/response.text()sin clonar elResponse. - El header de auth falta en los reintentos: el decorador Auth está dentro de Retry, así que corre una sola vez; muévelo fuera.
- Logging muestra latencia inflada: Logging está fuera de Retry y mide el tiempo total incluyendo el backoff. Muévelo dentro.
- Options mutadas entre reintentos: el decorador modificó
options.headersen el sitio. Clona primero. - El stack trace es ilegible: demasiadas capas; colapsa aspectos relacionados o cambia a un pipeline de middleware.
Lectura Adicional
- Refactoring Guru — Decorator: explicación canónica del GoF con diagramas de estructura.
- MDN — Fetch API: por qué
fetchno rechaza ante errores HTTP,Response.ok,AbortController. - OpenTelemetry JavaScript: decoradores de tracing que emiten spans.
- msw — Mock Service Worker: intercepta clientes decorados a nivel de red para tests de integración.
Preguntas frecuentes
¿Cómo se diferencia del middleware en Express?
El middleware de Express opera sobre objetos request/response en secuencia. Los decoradores envuelven una sola interfaz de cliente y se componen a cualquier granularidad: el llamador ve un HttpClient, no una cadena.
¿En qué orden deben aplicarse los decoradores?
El más externo corre primero. Pon Auth en la capa más externa para que los reintentos lleven el token. Pon Logging en la más interna para medir el tiempo real de red. Pon Metrics en la más externa para capturar la latencia completa incluyendo reintentos.
¿El decorador Retry debe reintentar ante todos los errores?
No. Reintenta solo ante fallos transitorios: errores de red, 502, 503, 504. Nunca reintentes ante 400, 401, 403, 404 o errores de validación — y recuerda que fetch resuelve ante errores HTTP, así que comprueba response.ok en lugar de depender de excepciones.
¿Cómo testeo un decorador?
Pasa un HttpClient mock que devuelva una respuesta fija o lance un error. Verifica que el decorador llama a través, modifica las options correctamente y re-lanza errores. Cada decorador debe ser testeable sin llamadas de red reales.
¿Cuál es la diferencia entre Decorator y Proxy?
Proxy controla el acceso a un objeto (lazy loading, control de acceso, caché). Decorator añade responsabilidades. La implementación parece similar (ambos envuelven el target), pero la intención difiere.
¿Cómo se comparan los decoradores con los interceptors?
Los interceptors (Axios, Angular HTTP) son hooks específicos del framework. Los decoradores son basados en interfaz y agnósticos al framework; si cambias de librería HTTP, los decoradores portan; los interceptors no.
¿Puedo usar este patrón con clientes GraphQL o gRPC?
Sí. Envuelve los métodos query/mutate del cliente (GraphQL) o unaryCall/serverStream (gRPC) en la misma interfaz. Los decoradores de auth, logging y retry funcionan idénticamente.
Recursos Relacionados
Patrón Decorator
Añade nueva funcionalidad a objetos dinámicamente envolviéndolos. Patrón de diseño estructural para extensión flexible de comportamiento.
PatternProxy Pattern para Cacheo de Respuestas de API
Como implementar un proxy de cacheo que intercepta llamadas a APIs y almacena respuestas para reducir latencia y evitar peticiones redundantes
PatternPatrón Adapter
Convierte la interfaz de una clase en otra interfaz que los clientes esperan. Patrón de diseño estructural para compatibilidad de interfaces.
PatternChain of Responsibility en Middleware de Peticiones
Pasa peticiones a lo largo de una cadena de handlers donde cada uno decide si procesa la petición o la delega al siguiente eslabón de la pipeline
PatternPrincipios SOLID en TypeScript con Ejemplos Practicos
Aplica los cinco principios SOLID a codigo TypeScript para mejorar mantenibilidad, testeabilidad y reducir acoplamiento en disenos orientados a objetos
RecipeLlamar a una API REST: Python, JS, Java y Go
Cómo hacer peticiones HTTP a una API REST y manejar la respuesta JSON en Python, JavaScript, Java y Go.