StackPractices
intermediate Por Mathias Paulenko

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.

flowchart diagram: Llamador

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() o response.text() consume el body. Usa response.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, pasa signal dentro de options y llama a abort() al llegar al deadline. El fetch interno entonces rechaza con AbortError, que los decoradores externos deben tratar como error no reintentable.

Decorator frente a alternativas

EnfoqueCómo componeAcoplamiento al frameworkCuándo elegirlo
DecoratorEnvuelve una interfaz compartida, a cualquier profundidadNinguno — TypeScript planoAspectos de cliente (auth, retry, logging) sobre un cliente
Pipeline de middlewareEtapas secuenciales sobre pares req/resExpress, Koa, frameworks de servidorManejo de peticiones del lado servidor
ProxyEnvuelve el target y controla el accesoNingunoLazy loading, control de acceso, caché — sin añadir comportamiento
InterceptorsHooks del framework (Axios, Angular)AltoCuando ya estás dentro de la capa HTTP de ese framework
Chain of ResponsibilityCada handler decide si procesa o reenvíaNingunoHandlers 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 options antes 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; usa response.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: fetch resuelve ante errores HTTP — comprueba response.ok; no dependas del catch.
  • El body de la respuesta ya fue consumido: un decorador leyó response.json()/response.text() sin clonar el Response.
  • 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.headers en el sitio. Clona primero.
  • El stack trace es ilegible: demasiadas capas; colapsa aspectos relacionados o cambia a un pipeline de middleware.

Lectura Adicional

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.