import { randomUUID } from "node:crypto"; import { ApiError, searchTimeout } from "./api-error.js"; import type { Config } from "./config.js"; import type { Logger } from "./log.js"; import { Pacer, type Clock, systemClock } from "./pacer.js"; import { VERSION } from "./version.js"; export type QueryValue = string | number | boolean | undefined; export interface ApiResponse { body: T; requestId: string; /** X-History-Clamped header or meta.historyClamped: the plan cut the start of the range. */ historyClamped: boolean; } export interface ApiClient { /** * GET a route such as `/sdk/markets/{id}`. `params` fill the `{name}` parts * (URL-encoded); `query` values that are undefined are left out. */ get( route: string, params: Record, query: Record, ): Promise>; } export interface ApiClientDeps { fetch: typeof fetch; clock: Clock; newRequestId: () => string; /** 0 <= x < 1, for retry jitter. */ random: () => number; timeoutSignal: (ms: number) => AbortSignal; } export const defaultApiClientDeps: ApiClientDeps = { fetch: (input, init) => fetch(input, init), clock: systemClock, newRequestId: () => randomUUID(), random: () => Math.random(), timeoutSignal: (ms) => AbortSignal.timeout(ms), }; /** No tool call should take longer than this in total, retries included (Codex's default tool timeout is 60 s). */ export const CALL_BUDGET_MS = 25_000; /** A Retry-After longer than this is reported to the model instead of waited out. */ export const MAX_RETRY_WAIT_MS = 10_000; const MAX_RATE_LIMIT_RETRIES = 2; const MAX_THROTTLE_RETRIES = 1; const MAX_TRANSIENT_RETRIES = 2; type Attempt = | { ok: true; response: ApiResponse } | { ok: false; error: ApiError }; export function buildUrl( baseUrl: string, route: string, params: Record, query: Record, ): string { const path = route.replace(/\{(\w+)\}/g, (_, name: string) => { const value = params[name]; if (value === undefined) throw new Error(`missing route parameter ${name}`); return encodeURIComponent(value); }); const url = new URL(`${baseUrl}${path}`); for (const [name, value] of Object.entries(query)) { if (value !== undefined) url.searchParams.set(name, String(value)); } return url.toString(); } function parseRetryAfter(header: string | null): number | undefined { if (!header) return undefined; const seconds = Number(header); return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined; } function asObject(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } export function createApiClient( config: Config, log: Logger, deps: ApiClientDeps = defaultApiClientDeps, ): ApiClient & { pacer: Pacer } { const { apiKey, baseUrl, timeoutMs } = config; const pacer = new Pacer(deps.clock); const userAgent = `assymetrix-mcp/${VERSION} (node/${process.versions.node})`; async function attempt( url: string, route: string, requestId: string, ): Promise> { const started = deps.clock.now(); let res: Response; try { res = await deps.fetch(url, { method: "GET", headers: { "x-api-key": apiKey ?? "", accept: "application/json", "user-agent": userAgent, "x-correlation-id": requestId, }, signal: deps.timeoutSignal(timeoutMs), // Never follow a redirect: it would carry x-api-key to whatever host the // Location names, perhaps over plain http. The API never needs one. redirect: "error", }); } catch (e) { const cause = e instanceof Error ? (e as Error & { cause?: unknown }).cause : undefined; if (cause instanceof Error && /redirect/i.test(cause.message)) { log.warn(`GET ${route} refused a redirect (request ${requestId})`); return { ok: false, error: new ApiError({ kind: "bad_response", message: "redirect refused", requestId }), }; } const name = e instanceof Error ? e.name : ""; const timedOut = name === "TimeoutError" || name === "AbortError"; log.warn( `GET ${route} ${timedOut ? "timed out" : "network error"} after ${deps.clock.now() - started} ms (request ${requestId})`, ); return { ok: false, error: new ApiError({ kind: timedOut ? "timeout" : "network", message: timedOut ? "timeout" : "network error", requestId, }), }; } log.info( `GET ${route} ${res.status} in ${deps.clock.now() - started} ms (request ${requestId})`, ); const perSecond = Number(res.headers.get("x-ratelimit-limit")); if (perSecond > 0) pacer.updateFromRateLimit(perSecond); let text: string; try { text = await res.text(); } catch { return { ok: false, error: new ApiError({ kind: "network", message: "reply cut off", requestId, }), }; } let parsed: unknown; try { parsed = text.length > 0 ? JSON.parse(text) : undefined; } catch { parsed = undefined; } if (res.ok) { if (parsed === undefined) { return { ok: false, error: new ApiError({ kind: "bad_response", message: "reply is not JSON", status: res.status, requestId, }), }; } const meta = asObject(asObject(parsed).meta); const historyClamped = res.headers.get("x-history-clamped") === "true" || meta.historyClamped === true; return { ok: true, response: { body: parsed as T, requestId, historyClamped }, }; } const body = asObject(parsed); return { ok: false, error: new ApiError({ kind: "http", message: `HTTP ${res.status}`, status: res.status, code: typeof body.code === "string" ? body.code : undefined, body, retryAfterSec: parseRetryAfter(res.headers.get("retry-after")), requestId, }), }; } /** How long to wait before retrying, or undefined to give up now. */ function retryDelay( err: ApiError, counts: { rate: number; throttle: number; transient: number }, ): number | undefined { if ( err.kind === "network" || err.kind === "timeout" || (err.kind === "http" && (err.status ?? 0) >= 500) ) { if (counts.transient >= MAX_TRANSIENT_RETRIES) return undefined; const delay = 500 * 2 ** counts.transient + Math.floor(deps.random() * 250); counts.transient += 1; return delay; } if (err.kind !== "http" || err.status !== 429) return undefined; // TIER_QUOTA_EXCEEDED: Retry-After is the time to the next period, which can be days. Never retry. // RATE_LIMIT_DENIED: the plan has no API rate at all. Never retry. if (err.code === "RATE_LIMIT_EXCEEDED") { if (counts.rate >= MAX_RATE_LIMIT_RETRIES) return undefined; const ms = Math.max(1_000, (err.retryAfterSec ?? 1) * 1_000); if (ms > MAX_RETRY_WAIT_MS) return undefined; counts.rate += 1; return ms; } if (err.code === undefined) { // The per-IP throttle (no `code` in its body). if (counts.throttle >= MAX_THROTTLE_RETRIES) return undefined; const ms = Math.max(1_000, (err.retryAfterSec ?? 1) * 1_000); if (ms > MAX_RETRY_WAIT_MS) return undefined; counts.throttle += 1; return ms; } return undefined; } return { pacer, async get( route: string, params: Record, query: Record, ) { if (!apiKey) throw new ApiError({ kind: "no_key", message: "ASSYMETRIX_API_KEY is not set", }); if (!baseUrl) throw new ApiError({ kind: "config", message: config.baseUrlProblem ?? "no base URL", }); const url = buildUrl(baseUrl, route, params, query); const requestId = deps.newRequestId(); const started = deps.clock.now(); const counts = { rate: 0, throttle: 0, transient: 0 }; for (;;) { const result = await pacer.schedule(() => attempt(url, route, requestId), ); if (result.ok) return result.response; const error = searchTimeout(result.error, route, query); if (error.searchTimeout) throw error; // the same search would only time out again const delay = retryDelay(error, counts); // Count the next attempt's worst case too, so a retry can never push // the whole call past the budget. if ( delay === undefined || deps.clock.now() - started + delay + timeoutMs > CALL_BUDGET_MS ) { throw error; } log.info(`retrying GET ${route} in ${delay} ms (request ${requestId})`); await deps.clock.sleep(delay); } }, }; }