http.ts
Language TypeScript4.0 KB118 lines
/*** Plinth HTTP layer.** Single endpoint, method dispatched by query parameter, static token. Every* guard from hazards.ts is applied here so no call site can bypass one:** - method allowlist (a GET with method=orders.setStatus would write and email customers)* - param allowlist (unknown params are dropped and return unfiltered results)* - 9-per-10s throttle (documented limit is 10/10s; one request of headroom)* - ambiguous errors ("Invalid request" means no rows or bad request; never retry it)*/import { TokenBucket, sleep } from '@meridian/shared'import {assertKnownParams,assertReadMethod,isEmptyOrInvalid,readError,type ReadMethod,} from './hazards.js'import { PLINTH_BASE_URL, PLINTH_RATE_LIMIT } from './constants.js'export interface PlinthOptions {token: stringbaseUrl?: stringmaxRetries?: numberfetchImpl?: typeof fetchsleep?: (ms: number) => Promise<void>debug?: boolean}export class PlinthError extends Error {constructor(message: string, readonly method: string, readonly status?: number) {super(`${message} [${method}]`)this.name = 'PlinthError'}}/** Rate limiting is transient here and should be retried, unlike "Invalid request". */export class PlinthRateLimitError extends PlinthError {constructor(method: string) {super('Plinth rate limit exceeded. Throttle is 9 per 10 seconds; something is calling faster.', method, 429)this.name = 'PlinthRateLimitError'}}export class PlinthClient {private readonly token: stringprivate readonly baseUrl: stringprivate readonly limiter: TokenBucketprivate readonly maxRetries: numberprivate readonly fetchImpl: typeof fetchprivate readonly sleep: (ms: number) => Promise<void>private readonly debug: booleanconstructor(opts: PlinthOptions) {if (!opts.token) throw new Error('PLINTH_API_KEY is not set.')this.token = opts.tokenthis.baseUrl = opts.baseUrl ?? PLINTH_BASE_URLthis.limiter = new TokenBucket(PLINTH_RATE_LIMIT.capacity, PLINTH_RATE_LIMIT.refillPerSecond)this.maxRetries = opts.maxRetries ?? 3this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis)this.sleep = opts.sleep ?? sleepthis.debug = opts.debug ?? false}/*** Returns the parsed body, or null when Plinth reports the ambiguous* "Invalid request", which means no rows, a bad request, or both, and cannot be* told apart. Callers treat null as empty and must not retry.*/async call<T = unknown>(method: ReadMethod,params: Record<string, string | number | undefined> = {},allowedParams: readonly string[] = [],): Promise<T | null> {assertReadMethod(method)if (allowedParams.length) assertKnownParams(method, params, allowedParams)const url = new URL(this.baseUrl)url.searchParams.set('api_key', this.token)url.searchParams.set('method', method)for (const [k, v] of Object.entries(params)) {if (v !== undefined) url.searchParams.set(k, String(v))}for (let attempt = 0; attempt <= this.maxRetries; attempt++) {await this.limiter.acquire()if (this.debug) console.error(`[plinth] ${method}${attempt ? ` retry=${attempt}` : ''}`)const res = await this.fetchImpl(url.toString(), { headers: { Accept: 'application/json' } })let body: unknowntry {body = await res.json()} catch {throw new PlinthError(`Non-JSON response (${res.status})`, method, res.status)}const err = readError(body)if (err?.startsWith('Too many requests')) {if (attempt < this.maxRetries) {await this.sleep(10_000)continue}throw new PlinthRateLimitError(method)}// Ambiguous by design. Never retried: a retry cannot turn "no rows" into rows.if (isEmptyOrInvalid(body)) return nullif (err) throw new PlinthError(err, method, res.status)return body as T}throw new PlinthRateLimitError(method)}}