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.
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()orresponse.text()consumes the body. Useresponse.clone()if you need to inspect the payload, or let streaming responses pass through untouched. - Request cancellation: an outer Timeout decorator creates an
AbortController, passessignalintooptions, and callsabort()on deadline. The innerfetchthen rejects withAbortError, which outer decorators should treat as a non-retryable error.
Decorator vs Alternatives
| Approach | How it composes | Framework coupling | When to pick it |
|---|---|---|---|
| Decorator | Wraps a shared interface, any depth | None — plain TypeScript | Client-side concerns (auth, retry, logging) on one client |
| Middleware pipeline | Sequential stages on req/res pairs | Express, Koa, server frameworks | Server-side request handling |
| Proxy | Wraps the target, controls access | None | Lazy loading, access control, caching — no new behavior added |
| Interceptors | Framework hooks (Axios, Angular) | High | When you are already inside that framework’s HTTP layer |
| Chain of Responsibility | Each handler decides to process or forward | None | Handlers 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; useresponse.clone()first. - Hardcoded config: pass retry counts, timeouts, and tokens through constructors so tests can vary them.
Troubleshooting
- RetryDecorator returns errors instead of retrying:
fetchresolves on HTTP errors. Checkresponse.ok, don’t rely oncatch. - Response body already consumed: a decorator read
response.json()/response.text()without cloning theResponse. - 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.headersin place. Clone first. - Stack trace is unreadable: too many layers; collapse related concerns or switch to a middleware pipeline.
Further Reading
- Refactoring Guru — Decorator: canonical GoF explanation with structure diagrams.
- MDN — Fetch API: why
fetchdoesn’t reject on HTTP errors,Response.ok,AbortController. - OpenTelemetry JavaScript: tracing decorators that emit spans.
- msw — Mock Service Worker: intercept decorated clients at the network layer for integration tests.
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.
Related Resources
Decorator Pattern
Add new functionality to objects dynamically by wrapping them. A structural design pattern for flexible behavior extension.
PatternProxy Pattern for API Response Caching
How to implement a caching proxy that intercepts API calls and stores responses to reduce latency and avoid redundant network requests
PatternAdapter Pattern
Convert the interface of a class into another interface clients expect. A structural design pattern for interface compatibility.
PatternChain of Responsibility for Request Processing Middleware
Pass requests along a chain of handlers where each handler decides whether to process the request or pass it to the next handler in the pipeline
PatternSOLID Principles in TypeScript with Practical Examples
Apply the five SOLID principles to TypeScript code to improve maintainability, testability, and reduce coupling in object-oriented designs
RecipeCall a REST API: Python, JavaScript, Java & Go Examples
How to make HTTP requests to a REST API and handle the JSON response in Python, JavaScript, Java, and Go.