StackPractices
intermediate By Mathias Paulenko

Decorator Pattern for HTTP Request Pipelines

Use the Decorator pattern to compose cross-cutting concerns like logging, metrics, and retries into HTTP request pipelines without modifying core logic

The Decorator pattern wraps an object to add responsibilities dynamically. Applied to HTTP clients, it composes cross-cutting concerns (logging, retries, metrics, authentication) around the core request logic instead of polluting it.

When to Use This

  • Several cross-cutting concerns must wrap every API call
  • You want to add or remove a concern without touching the others
  • The core request logic should stay testable and focused

Don’t reach for this when a single concern exists: a wrapper function is simpler than a decorator stack of one. If the concerns depend on request/response pairs flowing through stages, use a middleware pipeline instead.

Problem

Adding logging, retries, metrics, and auth to every HTTP call produces monolithic client classes or copy-paste boilerplate at every call site.

Solution

// 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 only throws on network errors — HTTP failures need checking
        if (response.ok || !RetryDecorator.retryableStatuses.has(response.status)) {
          return response;
        }
        lastError = new Error(`HTTP ${response.status} from ${url}`);
      } catch (error) {
        lastError = error; // network error: retryable
      }
      if (attempt < this.maxRetries) {
        // exponential backoff with jitter avoids synchronized retries
        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 });
  }
}

The important detail most examples get wrong: fetch doesn’t throw on HTTP error statuses. A retry decorator that only catches exceptions will happily return a 503 to the caller. Check response.ok inside the decorator and retry only on transient statuses.

Usage

const client = new AuthDecorator(
  new RetryDecorator(
    new LoggingDecorator(new FetchClient()),
    3
  ),
  process.env.API_TOKEN!
);

The outermost decorator (Auth) runs first, adding the token header. Then Retry catches failures and retries. Then Logging records timing. Finally FetchClient performs the actual network call. Each decorator wraps the next, forming a stack.

flowchart diagram: Caller

How It Works

Each decorator uses the same HttpClient interface and holds a reference to the next decorator in the chain. When request() runs, the decorator can modify the inputs, call the inner client, then modify or inspect the output. Because every layer shares the interface, they compose transparently; the caller never knows how many layers exist.

The order of decoration matters. Auth should wrap Retry so retries carry the token. Logging should sit innermost to measure real network time rather than total retry duration. Metrics should sit outermost to capture end-to-end latency.

Two edge cases worth knowing up front:

  • Streaming responses: a decorator that calls response.json() or response.text() consumes the body. Use response.clone() if you need to inspect the payload, or let streaming responses pass through untouched.
  • Request cancellation: an outer Timeout decorator creates an AbortController, passes signal into options, and calls abort() on deadline. The inner fetch then rejects with AbortError, which outer decorators should treat as a non-retryable error.

Decorator vs Alternatives

ApproachHow it composesFramework couplingWhen to pick it
DecoratorWraps a shared interface, any depthNone — plain TypeScriptClient-side concerns (auth, retry, logging) on one client
Middleware pipelineSequential stages on req/res pairsExpress, Koa, server frameworksServer-side request handling
ProxyWraps the target, controls accessNoneLazy loading, access control, caching — no new behavior added
InterceptorsFramework hooks (Axios, Angular)HighWhen you are already inside that framework’s HTTP layer
Chain of ResponsibilityEach handler decides to process or forwardNoneHandlers that may terminate the chain early (validation, authz)

Best Practices

  • Keep each decorator on one concern. A decorator that logs and retries is two decorators in disguise.
  • Clone the options object before mutating headers; mutating the original leaks state between retries.
  • Re-throw errors unless the decorator exists to handle them (like Retry). Swallowing errors breaks the chain contract.
  • Keep decorators stateless where possible; state like the circuit breaker’s failure count is the exception, not the rule.
  • Use a factory function to build the stack. Inline nesting gets unreadable past four layers:
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;
}
  • Test each decorator in isolation by passing a mock inner client: verify it calls through and transforms inputs correctly.

Circuit Breaker Example

For production calls to unreliable downstreams, add a circuit breaker. After threshold consecutive failures it opens and rejects requests for resetTimeout milliseconds, then lets a single request through to probe (half-open): if it succeeds the breaker closes, if it fails the timer restarts.

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; // allow one probe request
      } else {
        throw new Error(`Circuit breaker open for ${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();
    }
  }
}

Variations

  • Conditional Decorator: apply logic only for specific URLs or HTTP methods
  • Metrics Decorator: push timing and status distributions to Prometheus
  • Cache Decorator: combine with the Proxy pattern to cache GET responses
  • Timeout Decorator: abort requests past a deadline with AbortController
  • Rate Limit Decorator: cap concurrent or per-second requests
  • Tracing Decorator: inject trace IDs and emit spans to OpenTelemetry
  • Idempotency Decorator: generate an idempotency key header so retries of POST/PUT stay safe

Common Mistakes

  • Mutating the request object instead of cloning: retries and sibling decorators see the mutated state.
  • Swallowing errors: a decorator that catches but doesn’t re-throw hides failures from the caller.
  • Retrying everything: 4xx responses and non-idempotent POSTs shouldn’t be retried.
  • Wrong order: logging outside retry records total time including retry delays, which skews latency data.
  • Too many layers: past 5-6 decorators the stack becomes hard to trace; group related concerns instead.
  • Business logic in decorators: they handle transport concerns, not domain rules.
  • Reading response bodies without cloning: response.json() consumes the stream; use response.clone() first.
  • Hardcoded config: pass retry counts, timeouts, and tokens through constructors so tests can vary them.

Troubleshooting

  • RetryDecorator returns errors instead of retrying: fetch resolves on HTTP errors. Check response.ok, don’t rely on catch.
  • Response body already consumed: a decorator read response.json()/response.text() without cloning the Response.
  • Auth header missing on retries: the Auth decorator is inside Retry, so it runs once; move it outside.
  • Logging shows inflated latency: Logging is outside Retry and measures total time including backoff. Move it inside.
  • Options mutated between retries: the decorator modified options.headers in place. Clone first.
  • Stack trace is unreadable: too many layers; collapse related concerns or switch to a middleware pipeline.

Further Reading

Frequently Asked Questions

How is this different from middleware in Express?

Express middleware works on request/response objects in sequence. Decorators wrap a single client interface and compose at any granularity — the caller sees one HttpClient, not a chain.

What order should decorators be applied?

Outermost runs first. Put Auth outermost so retries carry the token. Put Logging innermost to measure actual network time. Put Metrics outermost to capture end-to-end latency including retries.

Should the Retry decorator retry on all errors?

No. Retry only on transient failures: network errors, 502, 503, 504. Never retry on 400, 401, 403, 404, or validation errors — and remember fetch resolves on HTTP errors, so check response.ok rather than relying on exceptions.

How do I test a decorator?

Pass a mock HttpClient that returns a fixed response or throws. Assert the decorator calls through, modifies options correctly, and re-throws errors. Each decorator should be testable without real network calls.

What's the difference between Decorator and Proxy?

Proxy controls access to an object (lazy loading, access control, caching). Decorator adds responsibilities. The implementation looks similar (both wrap the target), but the intent differs.

How do decorators compare to interceptors?

Interceptors (Axios, Angular HTTP) are framework-specific hooks. Decorators are interface-based and framework-agnostic: if you switch HTTP libraries, decorators port; interceptors don't.

Can I use this pattern with GraphQL or gRPC clients?

Yes. Wrap the client's query/mutate methods (GraphQL) or unaryCall/serverStream methods (gRPC) in the same interface. Auth, logging, and retry decorators work identically.