/** * @copyright Sister Software * @license AGPL-3.0 * @author Teffen Ellis, et al. * @file The default base for HTTP clients in this repo. Raw `fetch` duplicates throttling, caching, and * error mapping that live here; new clients extend or instantiate this instead (see `AGENTS.md`). */ import { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type CreateAxiosDefaults } from "axios"; import { type AxiosCacheInstance, type CacheOptions } from "axios-cache-interceptor"; import { type IRuntimeLogger } from "../logging/index.ts"; import { type ClockLike } from "./clock.ts"; import { type RetryOptions } from "./retry.ts"; export { type IRuntimeLogger } from "../logging/index.ts"; /** * Configuration for an API client. */ export interface APIClientConfig { /** * The logged display name of the API client. */ displayName: string; /** * Options for caching responses. */ caching?: CacheOptions; /** * How many requests to make per minute before enforcing a cooldown: a BUDGET model — spend `requestsPerMinute` * dispatches, then stall until the cooldown lapses. * * This cannot express a flat per-second rate, which is what most fair-access policies actually publish. For that use * {@linkcode minRequestIntervalMs}; the two compose (both gates must clear) but you almost certainly want one. */ requestsPerMinute?: number; /** * The minimum spacing between two dispatches, in milliseconds — strict pacing with NO burst allowance. * * Set this when an upstream publishes a flat rate (SEC EDGAR: 10 requests/second, enforced): `1000 / rate`. Unlike * {@linkcode requestsPerMinute}, the guarantee holds under arbitrary concurrency — grants are reserved synchronously, * so N callers racing in one turn are still spaced one interval apart. A token bucket cannot do this: capacity C * admits `C + rate * 1s` inside a sliding second, so no non-zero capacity honors a flat cap. */ minRequestIntervalMs?: number; /** * Bounded retry with exponential backoff, honoring a response's `Retry-After`. Pass `true` for the defaults. * * OPT-IN, and absent by default: an `APIClient` without this makes exactly one attempt, which is what every existing * consumer has always done. 429/5xx/408 and network-class failures (dropped socket, DNS, timeout, mid-body-transfer * drop) are retried; a 403 never is — it means the request failed to identify itself, so retrying can only fail * identically while burning rate budget. */ retry?: RetryOptions | boolean; /** * Time source powering the pacer, the cooldown timer, and the retry backoff. Defaults to {@linkcode systemClock}; * tests inject a fake clock so timing behavior is deterministic and instant. */ clock?: ClockLike; /** * Axios configuration. */ axios?: CreateAxiosDefaults; } /** * A base class for API clients used in Mailwoman, providing request pacing, response caching, bounded retry, mapped * errors, and integrated logging. */ export declare class APIClient extends EventTarget implements AsyncDisposable { #private; readonly config: C; get $cooldown(): Promise; /** * The prefixed logger for the API client. */ readonly logger: IRuntimeLogger; /** * The Axios instance for the API client. */ readonly axios: AxiosInstance | AxiosCacheInstance; constructor(config: C); /** * Perform a fetch operation using the API's Axios instance: served from cache when possible, paced and cooldown-gated * when not, retried within the configured ceiling, and — on the final failure — mapped to a {@linkcode ResourceError} * carrying a numeric `status` and a `(source, kind, reason)` URN. * * Error mapping happens HERE rather than in a response interceptor so the retry loop can see the raw `AxiosError` * (status AND `Retry-After`) before it is summarized. The pacing/cooldown gate deliberately does NOT happen here — it * sits in the adapter (see the constructor), downstream of the cache, so a hit costs nothing. Every retry attempt * re-enters `this.axios(...)` and therefore re-enters that gate; a retry burst cannot outrun the pacer. */ fetch: (options: AxiosRequestConfig) => Promise>; /** * Acquire permission to dispatch one request, clearing BOTH gates. Each reserves SYNCHRONOUSLY with respect to its * own state, so concurrency cannot defeat either of them. * * The bug this replaced: `fetch()` awaited a single `$cooldown` read and the request was only COUNTED by a response * interceptor. N callers invoked in the same turn all cleared the gate before any response came back to set a * cooldown — measured at 40 dispatches inside 3ms against a configured budget of 2/minute, and 40 against 10/minute. * * The pacer is re-acquired on every pass of the loop, NOT taken once up front. A grant is a claim on a specific * instant; blocking on a cooldown after taking one leaves it stale, and every caller holding a stale grant spends it * the moment the cooldown lifts — measured as four pairs dispatching 0ms apart against a documented 100ms minimum * when both gates were configured together. Re-acquiring discards the stale grant (the pacer under-issues by one per * cooldown wait, which is the safe direction) and takes a fresh one for the instant we actually dispatch. */ protected acquireDispatchSlot: () => Promise; protected setCooldown: (nextCooldown: number) => void; [Symbol.asyncDispose](): Promise; toString(): string; } //# sourceMappingURL=APIClient.d.ts.map