http.ts
Language TypeScript5.8 KB179 lines
/*** HTTP layer: bearer auth, rate limiting, retry, and error mapping.** Uses global fetch only. No Node built-ins, so this runs unchanged in a* Cloudflare Worker, in Node 20+, and in vitest.*/import {NinetyAuthError,NinetyError,NinetyForbiddenError,NinetyRateLimitError,type NinetyErrorContext,} from './errors.js'import { TokenBucket, backoffMs, sleep } from '@meridian/shared'export interface HttpOptions {token: stringbaseUrl?: string/*** Requests per second. Ninety documents 25 per user; the default is 20 so a* burst at the start of a sync does not trip 429 and turn a fast job slow.*/rps?: numbermaxRetries?: number/** Injectable for tests and for Workers. */fetchImpl?: typeof fetchsleep?: (ms: number) => Promise<void>debug?: boolean}export interface RequestOptions {body?: unknownquery?: Record<string, string | number | boolean | undefined>/** Included in error messages so a failure names its page. */pageIndex?: numbersignal?: AbortSignal}const DEFAULT_BASE_URL = 'https://api.public.ninety.io/v1'/** Retry jitter, so parallel workers do not resynchronize. */const JITTER_MS = 250export class HttpClient {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: HttpOptions) {if (!opts.token) {throw new Error('NINETY_TOKEN is missing. Generate a Personal Access Token at ' +'Ninety > User Settings > Developer Settings.',)}this.token = opts.tokenthis.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '')const rps = opts.rps ?? 20this.limiter = new TokenBucket(rps, rps)this.maxRetries = opts.maxRetries ?? 3this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis)this.sleep = opts.sleep ?? sleepthis.debug = opts.debug ?? false}async get<T = unknown>(path: string, opts: RequestOptions = {}): Promise<T> {return this.request<T>('GET', path, opts)}/** Note: Ninety's list endpoints are POST /{resource}/query, not GET. */async post<T = unknown>(path: string, opts: RequestOptions = {}): Promise<T> {return this.request<T>('POST', path, opts)}async request<T = unknown>(method: string, path: string, opts: RequestOptions = {}): Promise<T> {const url = this.buildUrl(path, opts.query)const ctx: NinetyErrorContext = { endpoint: `${method} ${path}` }if (opts.pageIndex !== undefined) ctx.pageIndex = opts.pageIndexlet lastRetryAfter: number | undefinedfor (let attempt = 0; attempt <= this.maxRetries; attempt++) {await this.limiter.acquire()if (this.debug) {console.error(`[ninety] ${method} ${path}${opts.pageIndex !== undefined ? ` page=${opts.pageIndex}` : ''}${attempt > 0 ? ` retry=${attempt}` : ''}`)}let res: Responsetry {const init: RequestInit = {method,headers: {Authorization: `Bearer ${this.token}`,Accept: 'application/json',...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),},...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),...(opts.signal ? { signal: opts.signal } : {}),}res = await this.fetchImpl(url, init)} catch (cause) {// Network-level failure. Retry, then give up with context.if (attempt < this.maxRetries) {await this.sleep(backoffMs(attempt, JITTER_MS))continue}throw new NinetyError(`Network error reaching Ninety: ${String(cause)}`, ctx)}if (res.ok) {if (res.status === 204) return undefined as Ttry {return (await res.json()) as T} catch (cause) {throw new NinetyError(`Ninety returned a non-JSON body: ${String(cause)}`, ctx, res.status)}}// 429 and 5xx are retryable. Everything else is not.if (res.status === 429) {lastRetryAfter = parseRetryAfter(res.headers.get('Retry-After'))if (attempt < this.maxRetries) {await this.sleep(lastRetryAfter ?? backoffMs(attempt, JITTER_MS))continue}throw new NinetyRateLimitError(ctx, lastRetryAfter)}if (res.status >= 500 && attempt < this.maxRetries) {await this.sleep(backoffMs(attempt, JITTER_MS))continue}ctx.body = truncate(await safeText(res), 500)if (res.status === 401) throw new NinetyAuthError(ctx)if (res.status === 403) throw new NinetyForbiddenError(ctx)throw new NinetyError(`Ninety returned ${res.status} ${res.statusText}`, ctx, res.status)}// Unreachable: every path above either returns or throws.throw new NinetyError('Exhausted retries', ctx)}private buildUrl(path: string, query?: RequestOptions['query']): string {const url = new URL(`${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`)if (query) {for (const [k, v] of Object.entries(query)) {if (v !== undefined) url.searchParams.set(k, String(v))}}return url.toString()}}function parseRetryAfter(header: string | null): number | undefined {if (!header) return undefinedconst seconds = Number(header)if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)const date = Date.parse(header)if (Number.isFinite(date)) return Math.max(0, date - Date.now())return undefined}async function safeText(res: Response): Promise<string> {try {return await res.text()} catch {return '<unreadable body>'}}function truncate(s: string, n: number): string {return s.length <= n ? s : `${s.slice(0, n)}...`}