Files
skills/docs/money/finance/currency-providers/src/http/http.md
2026-05-29 08:20:56 +01:00

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) 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

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 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<Response> — 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.