Skip to content

Instantly share code, notes, and snippets.

@alvinsng
Created July 10, 2026 21:35
Show Gist options
  • Select an option

  • Save alvinsng/fb7474edafef5e3f4a0e63e3c4888946 to your computer and use it in GitHub Desktop.

Select an option

Save alvinsng/fb7474edafef5e3f4a0e63e3c4888946 to your computer and use it in GitHub Desktop.
HttpBaseClient.ts
/**
* 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