http.ts
Language TypeScript4.3 KB118 lines
import { assertLiveRequestsAllowed, type PlinthConfig } from "./config.js";import { PlinthApiError, RateLimitError } from "./errors.js";import {assertReadMethod,assertSafeParams,assertUrlIsRead,RESERVED_PARAM_KEYS,type ReadMethod,} from "./guard.js";import { RATE_LIMIT_RETRY_MS, sleep, type RateLimiter } from "./rateLimit.js";/** Ambient context for a Plinth request: env-derived config + optional limiter. */export interface PlinthRequestContext {config: PlinthConfig;limiter?: RateLimiter;}/** Query params (excluding `method` and `api_key`, which are added here). */export type PlinthParams = Record<string, string | number | undefined>;/*** Build the request URL for a read method. Pure and side-effect free, so it is* safe to unit test without touching the network. `api_key` is appended by* plinthRequest().** Failsafe: server-controlled params (`method`, `api_key`) are skipped in the* caller loop and `method` is set LAST, so no caller-supplied param can* overwrite the dispatched method, even through the free-form passthrough tools.*/export function buildUrl(baseUrl: string, method: ReadMethod, params: PlinthParams): URL {assertReadMethod(method);const url = new URL(baseUrl);for (const [key, value] of Object.entries(params)) {if (RESERVED_PARAM_KEYS.has(key.trim().toLowerCase())) continue;if (value !== undefined && value !== "") {url.searchParams.set(key, String(value));}}// Set last so the dispatched method can never be shadowed by a caller param.url.searchParams.set("method", method);return url;}/*** Detect the API's documented error shapes. Success bodies never carry these* keys, so a hit means the call failed even though HTTP status was 200.** Note: a failed lookup can come back under `error` or as `notice: "No match"`;* on orders.splitShipments "No match" means "not a multi-address order" rather* than a hard failure. Callers disambiguate via PlinthApiError.raw.*/export function detectApiError(body: unknown): PlinthApiError | undefined {if (body && typeof body === "object") {const rec = body as Record<string, unknown>;const message =(typeof rec.error === "string" && rec.error) ||(typeof rec.notice === "string" && rec.notice) ||undefined;if (message) {if (/too many requests/i.test(message)) return new RateLimitError(body);return new PlinthApiError(message, body);}}return undefined;}/*** Execute a single read request against the Plinth API.** Runtime-agnostic: uses only `fetch` and `URL`, both native to the Cloudflare* Workers isolate and to Node 18+. Config is injected (no globals) so the same* function runs unchanged on either runtime.** Gated: assertLiveRequestsAllowed() throws unless the operator has explicitly* opted in (PLINTH_ALLOW_LIVE_REQUESTS=true and an API key).** Rate limiting: when a limiter is supplied on the context, every attempt first* awaits limiter.acquire(), which delays until a slot is free so the 10/10s cap* is never exceeded (see lib/rateLimit.ts). As a backstop, a RateLimitError* that still slips through triggers one retry after a full window.*/export async function plinthRequest(ctx: PlinthRequestContext,method: ReadMethod,params: PlinthParams = {},): Promise<unknown> {const { config, limiter } = ctx;assertLiveRequestsAllowed(config);// Failsafe layers 1 & 2: method must be on the read allowlist, and no// caller param may carry a reserved key or signal write intent.assertReadMethod(method);assertSafeParams(params);const url = buildUrl(config.baseUrl, method, params);url.searchParams.set("api_key", config.apiKey as string);// Failsafe layer 4: final check that the wire URL is a read before any I/O.assertUrlIsRead(url);const MAX_ATTEMPTS = 2;for (let attempt = 1; ; attempt++) {// Respect the 10/10s cap: the limiter delays until a slot is free.if (limiter) await limiter.acquire();const res = await fetch(url, { method: "GET" });const body: unknown = await res.json();const apiError = detectApiError(body);if (!apiError) return body;// Backstop: a rate-limit error slipped through; wait a window, retry once.if (apiError instanceof RateLimitError && attempt < MAX_ATTEMPTS) {await sleep(RATE_LIMIT_RETRY_MS);continue;}throw apiError;}}