Skip to content

feat(rest-client): resilient HTTP client with retry, circuit breaker, and correlation ID#8

Open
arjunmehta-git wants to merge 1 commit into
mainfrom
feat/rest-client-starter
Open

feat(rest-client): resilient HTTP client with retry, circuit breaker, and correlation ID#8
arjunmehta-git wants to merge 1 commit into
mainfrom
feat/rest-client-starter

Conversation

@arjunmehta-git

Copy link
Copy Markdown
Member

Summary

REST API client starter demonstrating resilience patterns. Closes #5.

Patterns included

  • Retry: exponential backoff + jitter on 429/5xx (max 3 attempts)
  • Circuit breaker: opossum, opens at 50% error rate, resets after 30s
  • Correlation ID: AsyncLocalStorage + X-Correlation-ID header propagation
  • Caching: in-memory TTL cache for GET responses
  • OTel: trace header injection on all requests

Status: Ready for review - leaving open for feedback before merge

…aker

- Exponential backoff with jitter (429, 5xx, max 3 retries)
- Circuit breaker via opossum (closed/open/half-open states)
- Correlation ID propagation via AsyncLocalStorage + X-Correlation-ID header
- GET response caching with TTL
- OpenTelemetry trace header injection
- Unit tests for retry and circuit breaker behavior

Signed-off-by: Alex Quincy <alexquincy@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 27, 2026 06:43

@hortison hortison left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The circuit breaker implementation looks right. Key note on the README Mermaid diagram - the half-open state description should say 'allows ONE test request' not 'allows requests'. The whole point of half-open is a single probe to test if the downstream recovered. Otherwise teams implement it wrong.

Also: the volumeThreshold for the circuit breaker (minimum calls before the error percentage is evaluated) should be documented. Default in opossum is pretty low - you might trip the circuit on 2 failures in the first few requests at startup. Recommend setting it to at least 10.

@miacycle miacycle left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love the correlation ID via AsyncLocalStorage pattern - this is the right way to do it. Express middleware is included which is great. One ask: add an example of how to use it with Fastify too, since we're recommending that for new services.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new rest-client/ Node.js starter that demonstrates resilient outbound HTTP usage: an Axios-based client wrapped with an opossum circuit breaker, exponential-backoff retry on 429/5xx, a TTL GET cache, AsyncLocalStorage-backed correlation ID propagation, a placeholder OTel header hook, plus a README and Jest tests. It slots into the existing starter catalog alongside kafka/ and addresses issue #5.

Changes:

  • New ResilientHttpClient with circuit breaker, retry-with-jitter, GET TTL cache, and correlation/OTel header injection.
  • New correlation-context.ts exposing AsyncLocalStorage-based getCorrelationId and an Express correlationMiddleware.
  • README documenting state machine, retry policy, usage, and config; Jest tests covering success, cache, retry, breaker, and correlation header.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.

File Description
rest-client/README.md Starter docs: breaker state diagram, retry policy, usage, config reference.
rest-client/nodejs/resilient-client.ts Core resilient HTTP client (Axios + opossum + retry + cache + OTel placeholder).
rest-client/nodejs/correlation-context.ts AsyncLocalStorage correlation ID storage and Express middleware.
rest-client/nodejs/tests/resilient-client.test.ts Jest tests for GET/cache/retry/breaker/correlation behaviors.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1 to +4
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import CircuitBreaker from 'opossum';
import { randomUUID } from 'crypto';
import { getCorrelationId } from './correlation-context';
Comment on lines +124 to +126
return this.withRetry(() =>
this.axios.request({ method, url: path, data: body, ...requestConfig }),
);
Comment on lines +42 to +85
private readonly cache = new Map<string, CacheEntry>();
private readonly cacheTtl: number;

constructor(options: ResilientHttpClientOptions) {
this.axios = axios.create({
baseURL: options.baseURL,
timeout: options.timeout ?? 10_000,
});

this.cacheTtl = options.cache?.ttl ?? 60_000;

// Wrap the internal request function with a circuit breaker.
// The breaker transitions: closed (normal) -> open (failing) -> half-open (testing).
this.breaker = new CircuitBreaker(this.executeRequest.bind(this), {
errorThresholdPercentage: options.circuitBreaker?.errorThresholdPercentage ?? 50,
timeout: options.circuitBreaker?.timeout ?? 5_000,
resetTimeout: options.circuitBreaker?.resetTimeout ?? 30_000,
// Minimum calls before the error percentage is evaluated.
// Prevents tripping on 1-2 startup failures before traffic ramps up.
volumeThreshold: options.circuitBreaker?.volumeThreshold ?? 10,
});

this.breaker.on('open', () => {
console.warn(`[ResilientHttpClient] Circuit breaker OPEN for ${options.baseURL}`);
});
this.breaker.on('halfOpen', () => {
console.info(`[ResilientHttpClient] Circuit breaker HALF-OPEN - sending test request`);
});
this.breaker.on('close', () => {
console.info(`[ResilientHttpClient] Circuit breaker CLOSED - resuming normal traffic`);
});
}

async get<T>(path: string, config?: AxiosRequestConfig): Promise<T> {
const cacheKey = `GET:${path}:${JSON.stringify(config?.params ?? {})}`;
const cached = this.cache.get(cacheKey);

if (cached && cached.expiresAt > Date.now()) {
return cached.data as T;
}

const response = await this.breaker.fire('GET', path, undefined, config) as AxiosResponse<T>;

this.cache.set(cacheKey, { data: response.data, expiresAt: Date.now() + this.cacheTtl });
Comment on lines +134 to +160
let lastError: Error | undefined;

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
const status = (err as { response?: { status: number } }).response?.status;
const isRetryable = status === 429 || (status !== undefined && status >= 500);

if (!isRetryable || attempt === maxAttempts) {
throw err;
}

lastError = err as Error;
const baseDelay = Math.pow(2, attempt) * 100;
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;

console.warn(
`[ResilientHttpClient] Attempt ${attempt}/${maxAttempts} failed (${status}), ` +
`retrying in ${Math.round(delay)}ms`,
);
await sleep(delay);
}
}

throw lastError;
Comment thread rest-client/README.md
Attempt 1: immediate
Attempt 2: ~200ms + jitter
Attempt 3: ~400ms + jitter
Attempt 4: give up, throw
Comment on lines +163 to +170
private getOtelHeaders(): Record<string, string> {
// Wire in your OpenTelemetry propagator here.
// Example with @opentelemetry/api:
// const carrier: Record<string, string> = {};
// propagation.inject(context.active(), carrier);
// return carrier;
return {};
}
Comment on lines +24 to +32
export function correlationMiddleware(req: Request, res: Response, next: NextFunction): void {
const correlationId =
(req.headers['x-correlation-id'] as string | undefined) ?? randomUUID();

res.setHeader('x-correlation-id', correlationId);

correlationStorage.run({ correlationId }, () => {
next();
});
Comment thread rest-client/README.md
Comment on lines +1 to +3
# REST API Client Starter - Resilience Patterns

Resilient HTTP client for Node.js services calling external REST APIs. Implements retry, circuit breaker, correlation ID propagation, and response caching.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Integration starter: REST API client with retry and circuit breaker

5 participants