6.0 KiB
http — Shared HTTP helper for sibling provider packages
(no submodules)
Purpose
Exposes the two HTTP utilities every sibling
@suaveplan/currency-provider-<name> package repeats: httpRequest (an
async request through the injected FetchLike with typed-error mapping and
abort-signal composition) and composeAbortSignal (a pure helper that
merges a caller AbortSignal with an optional timeout-derived one).
The previous shape of @suaveplan/currency-providers had every provider
re-implement the same await this.fetchImpl(url); if (!response.ok) throw new CurrencyProviderError(HTTP_ERROR, ...) block by hand. That repetition
is exactly the signal that the helper belongs in the slim core: every
sibling now import { httpRequest } from "@suaveplan/currency-providers"
and the HTTP_ERROR call site lives in one place, exercised by one test
file.
Features
httpRequest— single async helper covering the full HTTP lifecycle: abort-signal composition, timeout derivation, fetch invocation, non-2xx →CurrencyProviderError(HTTP_ERROR)mappingcomposeAbortSignal— pure four-way composition:undefined, caller only, timeout only, both (viaAbortSignal.any)- Returns the raw
Responseso callers handle.json()/.text()/.arrayBuffer()according to their own payload shape — no premature body-format assumption - Universal — uses only WHATWG
fetch,AbortSignal.timeout, andAbortSignal.any(all available in Node 20+, Bun, and modern browsers) - Typed error carries
providerNameandstatusdiscriminants so audit log handlers can dispatch without parsing the message string
Usage: Basic Example
import { httpRequest } from "@suaveplan/currency-providers";
const response = await httpRequest({
url: "https://api.example.com/v1/rates",
fetch: globalThis.fetch,
providerName: "example",
});
const payload = await response.json();
Usage: Advanced Example
Wire httpRequest into a sibling provider with both a timeout and the
caller's RateFetchOptions.signal, then handle HTTP_ERROR against the
typed code rather than the message:
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
import type { RateFetchOptions, RateFetchResult } from "@suaveplan/currency";
const DEFAULT_URL = "https://api.example.com/v1/rates";
const NAME = "example";
export class ExampleProvider {
public readonly name = NAME;
public readonly supportsCrypto = false;
public readonly supportsFiat = true;
private readonly baseUrl: string;
private readonly fetchImpl;
constructor(config?: NoKeyConfig) {
this.baseUrl = config?.baseUrl ?? DEFAULT_URL;
this.fetchImpl = config?.fetch ?? globalThis.fetch;
}
async fetchRates(options?: RateFetchOptions): Promise<RateFetchResult> {
try {
const response = await httpRequest({
url: this.baseUrl,
fetch: this.fetchImpl,
providerName: NAME,
signal: options?.signal,
timeoutMs: 10_000,
});
const payload = (await response.json()) as {
base: string;
rates: Record<string, number>;
timestamp: number;
};
return {
base: payload.base,
rates: payload.rates,
timestamp: payload.timestamp * 1000,
};
} catch (error) {
if (
error instanceof CurrencyProviderError &&
error.code === CURRENCY_PROVIDER_CODES.HTTP_ERROR
) {
// optionally: backoff, fallback, audit-log etc.
}
throw error;
}
}
}
API Reference
interface HttpRequestInputurl: string— fully-resolved request URL (query string already appended at the call site).fetch: FetchLike— injectedFetchLike(the sibling provider defaults toglobalThis.fetchat construction time).providerName: string— carried on the raisedCurrencyProviderError.providerName.init?: RequestInit— optional WHATWGRequestInit(method, headers, body, etc.). The helper preserves caller-suppliedheadersandmethod; it only insertssignalwhen an abort signal needs to be attached.signal?: AbortSignal— caller's abort signal (usually fromRateFetchOptions.signal).timeoutMs?: number— when set, the helper derivesAbortSignal.timeout(timeoutMs)and composes it with the caller signal.
function httpRequest(input: HttpRequestInput): Promise<Response>— resolves to the rawResponseon 2xx, rejects withCurrencyProviderError(HTTP_ERROR)on non-2xx.function composeAbortSignal(caller, timeout): AbortSignal | undefined— pure four-way composition. Returns the single input verbatim when only one is provided (no wrapping cost). ReturnsAbortSignal.any([caller, timeout])when both are present.
Implementation Notes
- The helper does not apply retry, backoff, or rate-limit semantics —
those wrappers compose at a higher layer (see the deferred surfaces in
the 2026-05-17 archive's
## Out of scope). AbortSignal.timeoutwas chosen over a manualsetTimeout + AbortControllerto keep the helper universal (nosetTimeoutscheduling owned by the helper) and to avoid the cleanup foot-gun of a pending timer outliving a fast-success request.composeAbortSignaldeliberately returns the single input unchanged when only one is provided. Wrapping a single signal throughAbortSignal.any([s])would still produce a valid signal but would break identity-comparison invariants downstream consumers might rely on.- The non-2xx error message intentionally uses
response.statusText(the HTTP reason phrase) so the human-readable form survives audit log redaction; programmatic dispatch should useerror.codeanderror.statusinstead.