Created
July 10, 2026 21:35
-
-
Save alvinsng/fb7474edafef5e3f4a0e63e3c4888946 to your computer and use it in GitHub Desktop.
HttpBaseClient.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * HttpBaseClient — a self-contained, framework-agnostic abstract HTTP client. | |
| * | |
| * Design goals: | |
| * - Single file, zero dependencies (Node 18+ / browser). | |
| * - Every outbound HTTP call goes through one pipeline so you get | |
| * consistent logging, error mapping, metrics, and query/body | |
| * serialization for free. | |
| * - Subclasses focus on vendor specifics: base URL, auth headers, | |
| * and optional error-body translation. | |
| * | |
| * Usage: | |
| * 1. Extend `HttpBaseClient`, set `baseUrl`, implement | |
| * `buildAuthHeaders()`. | |
| * 2. Optionally override `mapHttpError()` to parse vendor error bodies. | |
| * 3. Call `this.get/post/put/patch/delete()` from public methods. | |
| * | |
| * See the example `AcmeClient` at the bottom of this file. | |
| */ | |
| /* ──────────────────────────────── Enums ──────────────────────────────── */ | |
| export enum HttpMethod { | |
| GET = 'GET', | |
| POST = 'POST', | |
| PUT = 'PUT', | |
| PATCH = 'PATCH', | |
| DELETE = 'DELETE', | |
| } | |
| /* ──────────────────────────────── Types ──────────────────────────────── */ | |
| /** | |
| * Opaque string identifying an external service (e.g. `"acme"`). | |
| * Replace with your own enum or union if you want compile-time exhaustiveness. | |
| */ | |
| export type ExternalDependency = string; | |
| /** Acceptable query-parameter value types. */ | |
| export type Primitive = string | number | boolean | null | undefined; | |
| /** Structured metadata attached to errors and log calls. */ | |
| export type LogMetadata = Record<string, unknown>; | |
| /** Labels pushed to the metrics layer for each request. */ | |
| export type MetricLabels = Record<string, string>; | |
| /** | |
| * Identifies an endpoint on a dependency. | |
| * If you maintain an endpoint registry you can narrow this per-dependency; | |
| * otherwise it is just a string like `"acme/getUser"`. | |
| */ | |
| export type EndpointOf<TDep extends ExternalDependency> = string; | |
| /** Full per-request configuration consumed by `HttpBaseClient.request()`. */ | |
| export interface RequestConfig< | |
| TIn, | |
| TDep extends ExternalDependency = ExternalDependency, | |
| > { | |
| method: HttpMethod; | |
| path: string; | |
| query?: Record<string, Primitive>; | |
| body?: TIn; | |
| headers?: Record<string, string>; | |
| endpoint: EndpointOf<TDep>; | |
| } | |
| /** Convenience subset passed to the verb helpers. */ | |
| export type RequestOpts<TDep extends ExternalDependency = ExternalDependency> = | |
| Omit<RequestConfig<unknown, TDep>, 'method' | 'path' | 'body'>; | |
| /** Options forwarded to the metrics layer. */ | |
| export interface ClientMetricOptions { | |
| labels?: MetricLabels; | |
| isUserError?: (error: unknown) => boolean; | |
| } | |
| /* ──────────────────────────── Error classes ──────────────────────────── */ | |
| /** | |
| * HTTP error with a numeric `statusCode`. Callers branch on | |
| * `err.statusCode` (e.g. `if (err.statusCode === 404)`) instead of | |
| * `instanceof` subclasses. | |
| */ | |
| export class ResponseError extends Error { | |
| readonly statusCode: number; | |
| metadata?: LogMetadata; | |
| constructor( | |
| message: string, | |
| statusCode: number, | |
| metadata?: LogMetadata, | |
| ) { | |
| super(message); | |
| this.name = 'ResponseError'; | |
| this.metadata = metadata; | |
| this.statusCode = statusCode; | |
| Object.setPrototypeOf(this, ResponseError.prototype); | |
| } | |
| } | |
| /* ────────────────────────── Utility functions ────────────────────────── */ | |
| /** Map an HTTP status code to a `ResponseError`. */ | |
| export function mapStatusToResponseError( | |
| status: number, | |
| message: string, | |
| metadata?: LogMetadata, | |
| ): Error { | |
| return new ResponseError(message, status, { statusCode: status, ...metadata }); | |
| } | |
| /** | |
| * Returns `true` when `fetch` itself failed at the transport layer | |
| * (DNS, TCP reset, TLS, timeout) rather than returning an HTTP response. | |
| */ | |
| export function isTransientFetchError(error: unknown): boolean { | |
| if (error instanceof TypeError && error.message.includes('fetch failed')) { | |
| return true; | |
| } | |
| return error instanceof Error && error.name === 'TimeoutError'; | |
| } | |
| /** | |
| * Returns `true` when `fetch` itself failed at the transport layer | |
| * (DNS, TCP reset, TLS, timeout) rather than returning an HTTP response. | |
| */ | |
| /** | |
| * Replace these with your own logger (pino, winston, Sentry, etc.). | |
| * The signatures stay the same: `(message, metadata?)`. | |
| */ | |
| export function logInfo(message: string, metadata?: LogMetadata): void { | |
| console.info(`[INFO] ${message}`, metadata ?? ''); | |
| } | |
| export function logWarn(message: string, metadata?: LogMetadata): void { | |
| console.warn(`[WARN] ${message}`, metadata ?? ''); | |
| } | |
| /* ──────────────────────────── Metrics layer ──────────────────────────── */ | |
| /** | |
| * Placeholder metrics wrapper — does nothing except call `fn`. | |
| * Replace the body with your own metrics library (Prometheus, StatsD, | |
| * OpenTelemetry, etc.) using `dependency` and `options.labels` to tag | |
| * counters and histograms. | |
| */ | |
| export async function callWithMetrics<T>( | |
| fn: () => Promise<T>, | |
| _dependency: ExternalDependency, | |
| _options?: ClientMetricOptions, | |
| ): Promise<T> { | |
| return fn(); | |
| } | |
| /* ──────────────────────────── BaseClient ─────────────────────────────── */ | |
| /** | |
| * Wraps an outbound call with metric recording and validates that the | |
| * `endpoint` label belongs to the dependency the client was constructed | |
| * with (e.g. `"acme/getUser"` must start with `"acme/"`). | |
| */ | |
| export abstract class BaseClient<TDep extends ExternalDependency> { | |
| protected readonly dependency: TDep; | |
| protected constructor(dependency: TDep) { | |
| this.dependency = dependency; | |
| } | |
| protected async performRequest<T>( | |
| endpoint: EndpointOf<TDep>, | |
| fn: () => Promise<T>, | |
| options?: ClientMetricOptions, | |
| ): Promise<T> { | |
| const endpointValue = String(endpoint); | |
| if (!endpointValue.startsWith(`${this.dependency}/`)) { | |
| throw new Error( | |
| `BaseClient: endpoint "${endpointValue}" does not belong to dependency "${this.dependency}"`, | |
| ); | |
| } | |
| const labels = { ...(options?.labels ?? {}), endpoint: endpointValue }; | |
| return callWithMetrics(fn, this.dependency, { ...options, labels }); | |
| } | |
| } | |
| /* ───────────────────────── HttpBaseClient ────────────────────────────── */ | |
| /** | |
| * Abstract HTTP client. The only place in your codebase that should | |
| * call `fetch` directly — every other outbound HTTP call goes through a | |
| * subclass. | |
| * | |
| * Subclasses: | |
| * - set `baseUrl` and (optionally) override `defaultHeaders` | |
| * - implement `buildAuthHeaders` to attach vendor credentials | |
| * - may override `mapHttpError` to translate vendor error envelopes | |
| * into `ResponseError` | |
| */ | |
| export abstract class HttpBaseClient< | |
| TDep extends ExternalDependency, | |
| TErrBody = unknown, | |
| > extends BaseClient<TDep> { | |
| protected abstract readonly baseUrl: string; | |
| /** | |
| * Base headers applied to every request before auth + per-call headers. | |
| * Subclasses may override to inject `Accept`, API version, User-Agent, etc. | |
| */ | |
| protected defaultHeaders(): Record<string, string> { | |
| return {}; | |
| } | |
| /** | |
| * Vendor-specific authentication headers. Called on every request. | |
| * Implementations are responsible for their own memoization (e.g. to | |
| * avoid fetching a secret every call). | |
| */ | |
| protected abstract buildAuthHeaders(): | |
| | Record<string, string> | |
| | Promise<Record<string, string>>; | |
| /** | |
| * Translate a non-2xx response into an `Error`. Default behavior maps | |
| * the HTTP status to a `ResponseError`. Subclasses typically override | |
| * to extract vendor-specific error type/code/message from the parsed | |
| * body before falling back to `mapStatusToResponseError`. | |
| */ | |
| protected mapHttpError(res: Response, _body: unknown): Error { | |
| const statusText = res.statusText || 'HTTP error'; | |
| const message = `Upstream ${this.dependency} responded ${res.status} ${statusText}`; | |
| return mapStatusToResponseError(res.status, message, { | |
| statusCode: res.status, | |
| }); | |
| } | |
| /** | |
| * Core request pipeline. Subclasses call this (or the verb helpers | |
| * below) from their public methods. | |
| */ | |
| protected async request<TIn = unknown, TOut = unknown, TErr = TErrBody>( | |
| cfg: RequestConfig<TIn, TDep>, | |
| ): Promise<TOut> { | |
| const url = this.buildUrl(cfg.path, cfg.query); | |
| const method: HttpMethod = cfg.method; | |
| const endpointValue = String(cfg.endpoint); | |
| const authHeaders = await this.buildAuthHeaders(); | |
| const headers: Record<string, string> = { | |
| ...this.defaultHeaders(), | |
| ...authHeaders, | |
| ...(cfg.headers ?? {}), | |
| }; | |
| const init: RequestInit = { method }; | |
| const hasBody = | |
| cfg.body !== undefined && cfg.body !== null && method !== HttpMethod.GET; | |
| if (hasBody) { | |
| init.body = JSON.stringify(cfg.body); | |
| headers['Content-Type'] ??= 'application/json'; | |
| } | |
| init.headers = headers; | |
| // `endpoint` itself is injected as a metric label by | |
| // `BaseClient.performRequest`; we only add the HTTP method here so | |
| // it shows up alongside it. | |
| const metricLabels: Record<string, string> = { method }; | |
| const baseLogMetadata: LogMetadata = { | |
| serviceName: this.dependency, | |
| method, | |
| url, | |
| endpoint: endpointValue, | |
| }; | |
| logInfo('HttpBaseClient - Upstream request starting', baseLogMetadata); | |
| const startTime = performance.now(); | |
| // `response` / `rawText` / `parsed` are captured in the outer scope | |
| // so the single `catch` below can enrich its log with whatever | |
| // wire-level state we managed to observe before the failure. | |
| let response: Response | undefined; | |
| let rawText: string | undefined; | |
| let parsed: unknown; | |
| try { | |
| response = await this.performRequest<Response>( | |
| cfg.endpoint, | |
| () => fetch(url, init), | |
| { labels: metricLabels }, | |
| ); | |
| rawText = await this.readResponseBody(response); | |
| parsed = this.parseResponseBody(response, rawText); | |
| if (!response.ok) { | |
| throw this.mapHttpError(response, parsed as TErr | string); | |
| } | |
| logInfo('HttpBaseClient - Upstream request succeeded', { | |
| ...baseLogMetadata, | |
| statusCode: response.status, | |
| durationMs: performance.now() - startTime, | |
| }); | |
| return parsed as TOut; | |
| } catch (rawCause) { | |
| const error = this.translateTransportError(rawCause, method, url); | |
| logWarn('HttpBaseClient - Upstream request failed', { | |
| ...baseLogMetadata, | |
| durationMs: performance.now() - startTime, | |
| ...(response !== undefined ? { statusCode: response.status } : {}), | |
| ...(parsed !== undefined ? { body: parsed } : {}), | |
| ...(rawText !== undefined | |
| ? { bodyPreview: rawText.slice(0, 1000) } | |
| : {}), | |
| cause: error, | |
| }); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Translate a raw error thrown by `fetch` into our error hierarchy. | |
| * A transport-level failure (`TypeError: fetch failed` — DNS, TCP reset, | |
| * TLS failure, etc. — or a deadline `TimeoutError`) is the runtime's | |
| * signal that the connection itself never succeeded, which we surface as | |
| * 503 so callers treat the dependency as unavailable. All other errors | |
| * pass through unchanged. | |
| */ | |
| private translateTransportError( | |
| cause: unknown, | |
| method: HttpMethod, | |
| url: string, | |
| ): unknown { | |
| if (isTransientFetchError(cause)) { | |
| return new ResponseError( | |
| `Upstream ${this.dependency} is unavailable. Please try again later.`, | |
| 503, | |
| { cause, method, url, serviceName: this.dependency }, | |
| ); | |
| } | |
| return cause; | |
| } | |
| protected get<TOut = unknown, TErr = TErrBody>( | |
| path: string, | |
| query: Record<string, Primitive> | undefined, | |
| opts: RequestOpts<TDep>, | |
| ): Promise<TOut> { | |
| return this.request<undefined, TOut, TErr>({ | |
| method: HttpMethod.GET, | |
| path, | |
| query, | |
| ...opts, | |
| }); | |
| } | |
| protected post<TIn = unknown, TOut = unknown, TErr = TErrBody>( | |
| path: string, | |
| body: TIn | undefined, | |
| opts: RequestOpts<TDep>, | |
| ): Promise<TOut> { | |
| return this.request<TIn, TOut, TErr>({ | |
| method: HttpMethod.POST, | |
| path, | |
| body, | |
| ...opts, | |
| }); | |
| } | |
| protected put<TIn = unknown, TOut = unknown, TErr = TErrBody>( | |
| path: string, | |
| body: TIn | undefined, | |
| opts: RequestOpts<TDep>, | |
| ): Promise<TOut> { | |
| return this.request<TIn, TOut, TErr>({ | |
| method: HttpMethod.PUT, | |
| path, | |
| body, | |
| ...opts, | |
| }); | |
| } | |
| protected patch<TIn = unknown, TOut = unknown, TErr = TErrBody>( | |
| path: string, | |
| body: TIn | undefined, | |
| opts: RequestOpts<TDep>, | |
| ): Promise<TOut> { | |
| return this.request<TIn, TOut, TErr>({ | |
| method: HttpMethod.PATCH, | |
| path, | |
| body, | |
| ...opts, | |
| }); | |
| } | |
| protected delete<TOut = unknown, TErr = TErrBody>( | |
| path: string, | |
| query: Record<string, Primitive> | undefined, | |
| opts: RequestOpts<TDep>, | |
| ): Promise<TOut> { | |
| return this.request<undefined, TOut, TErr>({ | |
| method: HttpMethod.DELETE, | |
| path, | |
| query, | |
| ...opts, | |
| }); | |
| } | |
| private buildUrl( | |
| path: string, | |
| query?: Record<string, Primitive | Primitive[]>, | |
| ): string { | |
| const trimmedBase = this.baseUrl.replace(/\/+$/, ''); | |
| const prefixed = path.startsWith('/') ? path : `/${path}`; | |
| const base = `${trimmedBase}${prefixed}`; | |
| if (!query) return base; | |
| const params = new URLSearchParams(); | |
| for (const [key, value] of Object.entries(query)) { | |
| if (value === undefined || value === null) continue; | |
| if (Array.isArray(value)) { | |
| for (const item of value) { | |
| if (item === undefined || item === null) continue; | |
| params.append(key, String(item)); | |
| } | |
| continue; | |
| } | |
| params.append(key, String(value)); | |
| } | |
| const queryString = params.toString(); | |
| if (!queryString) return base; | |
| const separator = base.includes('?') ? '&' : '?'; | |
| return `${base}${separator}${queryString}`; | |
| } | |
| /** | |
| * Read the response body as raw text. Returns `undefined` for 204 No | |
| * Content or any other response with an empty body. | |
| */ | |
| private async readResponseBody( | |
| res: Response, | |
| ): Promise<string | undefined> { | |
| if (res.status === 204) return undefined; | |
| const text = await res.text(); | |
| return text.length > 0 ? text : undefined; | |
| } | |
| /** | |
| * Parse a previously-read body according to the response's | |
| * content-type. JSON responses that fail to decode surface as a 503. | |
| */ | |
| private parseResponseBody( | |
| res: Response, | |
| rawText: string | undefined, | |
| ): unknown { | |
| if (rawText === undefined) return undefined; | |
| const contentTypeHdr = res.headers.get('content-type') ?? ''; | |
| if (contentTypeHdr.includes('application/json')) { | |
| try { | |
| return JSON.parse(rawText); | |
| } catch (cause) { | |
| throw new ResponseError( | |
| `Upstream ${this.dependency} returned a non-JSON response body`, | |
| 503, | |
| { | |
| cause, | |
| statusCode: res.status, | |
| serviceName: this.dependency, | |
| bodyPreview: rawText.slice(0, 500), | |
| }, | |
| ); | |
| } | |
| } | |
| return rawText; | |
| } | |
| } | |
| /* ════════════════════════════════════════════════════════════════════════ | |
| * Example: AcmeClient — a dummy vendor API client | |
| * | |
| * Shows how to subclass HttpBaseClient, wire auth, override | |
| * mapHttpError, and expose typed public methods. Run this file | |
| * directly (`npx tsx http-client-gist.ts`) to see it in action. | |
| * ════════════════════════════════════════════════════════════════════════ */ | |
| // ── Vendor response / error types ────────────────────────────────────── | |
| interface AcmeUser { | |
| id: string; | |
| email: string; | |
| name: string; | |
| } | |
| interface AcmeErrorBody { | |
| error: { | |
| code: string; | |
| message: string; | |
| }; | |
| } | |
| // ── Client implementation ────────────────────────────────────────────── | |
| const ACME = 'acme' as const; | |
| class AcmeClient extends HttpBaseClient<typeof ACME, AcmeErrorBody> { | |
| protected readonly baseUrl = 'https://api.acme.com/v1'; | |
| private readonly apiKey: string; | |
| constructor(apiKey: string) { | |
| super(ACME); | |
| this.apiKey = apiKey; | |
| } | |
| protected defaultHeaders(): Record<string, string> { | |
| return { Accept: 'application/json', 'User-Agent': 'my-app/1.0' }; | |
| } | |
| protected async buildAuthHeaders(): Promise<Record<string, string>> { | |
| // In real life, fetch from a secret manager and memoize. | |
| return { Authorization: `Bearer ${this.apiKey}` }; | |
| } | |
| /** | |
| * Override mapHttpError to extract the vendor's structured error body | |
| * before falling back to the default status-based mapping. | |
| */ | |
| protected mapHttpError(res: Response, body: unknown): Error { | |
| if ( | |
| body && | |
| typeof body === 'object' && | |
| 'error' in body && | |
| typeof (body as AcmeErrorBody).error?.message === 'string' | |
| ) { | |
| const vendorMsg = (body as AcmeErrorBody).error.message; | |
| const message = `Acme API error: ${vendorMsg}`; | |
| return mapStatusToResponseError(res.status, message, { | |
| statusCode: res.status, | |
| vendorErrorCode: (body as AcmeErrorBody).error.code, | |
| }); | |
| } | |
| // No structured body — fall back to the default behavior. | |
| return super.mapHttpError(res, body); | |
| } | |
| // ── Public API methods ─────────────────────────────────────────────── | |
| async getUser(userId: string): Promise<AcmeUser> { | |
| return this.get<AcmeUser, AcmeErrorBody>( | |
| `/users/${userId}`, | |
| undefined, | |
| { endpoint: 'acme/getUser' }, | |
| ); | |
| } | |
| } | |
| // ── Caller demo (runs when executed directly) ────────────────────────── | |
| async function main() { | |
| const client = new AcmeClient('sk-demo-key'); | |
| try { | |
| const user = await client.getUser('usr_123'); | |
| console.log('getUser:', user); | |
| } catch (err) { | |
| if (err instanceof ResponseError) { | |
| if (err.statusCode === 404) console.log('getUser: user not found'); | |
| else if (err.statusCode === 401) console.log('getUser: bad API key'); | |
| else if (err.statusCode === 503) console.log('getUser: acme is down'); | |
| else console.error('getUser: HTTP error', err.statusCode, err.message); | |
| } else { | |
| console.error('getUser: unexpected error', err); | |
| } | |
| } | |
| } | |
| // Run if executed directly (ESM / CJS compatible). | |
| if (typeof require !== 'undefined' && require.main === module) { | |
| main(); | |
| } else if (import.meta.url === `file://${process.argv[1]}`) { | |
| main(); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment