# `http` — Shared HTTP helper for sibling provider packages _(no submodules)_ ## Purpose Exposes the two HTTP utilities every sibling `@suaveplan/currency-provider-` 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)` mapping - `composeAbortSignal` — pure four-way composition: `undefined`, caller only, timeout only, both (via `AbortSignal.any`) - Returns the raw `Response` so callers handle `.json()` / `.text()` / `.arrayBuffer()` according to their own payload shape — no premature body-format assumption - Universal — uses only WHATWG `fetch`, `AbortSignal.timeout`, and `AbortSignal.any` (all available in Node 20+, Bun, and modern browsers) - Typed error carries `providerName` and `status` discriminants so audit log handlers can dispatch without parsing the message string ## Usage: Basic Example ```ts 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: ```ts 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 { 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; 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 HttpRequestInput` - `url: string` — fully-resolved request URL (query string already appended at the call site). - `fetch: FetchLike` — injected `FetchLike` (the sibling provider defaults to `globalThis.fetch` at construction time). - `providerName: string` — carried on the raised `CurrencyProviderError.providerName`. - `init?: RequestInit` — optional WHATWG `RequestInit` (method, headers, body, etc.). The helper preserves caller-supplied `headers` and `method`; it only inserts `signal` when an abort signal needs to be attached. - `signal?: AbortSignal` — caller's abort signal (usually from `RateFetchOptions.signal`). - `timeoutMs?: number` — when set, the helper derives `AbortSignal.timeout(timeoutMs)` and composes it with the caller signal. - `function httpRequest(input: HttpRequestInput): Promise` — resolves to the raw `Response` on 2xx, rejects with `CurrencyProviderError(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). Returns `AbortSignal.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.timeout` was chosen over a manual `setTimeout + AbortController` to keep the helper universal (no `setTimeout` scheduling owned by the helper) and to avoid the cleanup foot-gun of a pending timer outliving a fast-success request. - `composeAbortSignal` deliberately returns the **single input** unchanged when only one is provided. Wrapping a single signal through `AbortSignal.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 use `error.code` and `error.status` instead.