mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

http.ts

Language TypeScript5.8 KB179 lines

packages/ninety-client/src/http.ts

  1. /**
  2. * HTTP layer: bearer auth, rate limiting, retry, and error mapping.
  3. *
  4. * Uses global fetch only. No Node built-ins, so this runs unchanged in a
  5. * Cloudflare Worker, in Node 20+, and in vitest.
  6. */
  7. import {
  8. NinetyAuthError,
  9. NinetyError,
  10. NinetyForbiddenError,
  11. NinetyRateLimitError,
  12. type NinetyErrorContext,
  13. } from './errors.js'
  14. import { TokenBucket, backoffMs, sleep } from '@meridian/shared'
  15. export interface HttpOptions {
  16. token: string
  17. baseUrl?: string
  18. /**
  19. * Requests per second. Ninety documents 25 per user; the default is 20 so a
  20. * burst at the start of a sync does not trip 429 and turn a fast job slow.
  21. */
  22. rps?: number
  23. maxRetries?: number
  24. /** Injectable for tests and for Workers. */
  25. fetchImpl?: typeof fetch
  26. sleep?: (ms: number) => Promise<void>
  27. debug?: boolean
  28. }
  29. export interface RequestOptions {
  30. body?: unknown
  31. query?: Record<string, string | number | boolean | undefined>
  32. /** Included in error messages so a failure names its page. */
  33. pageIndex?: number
  34. signal?: AbortSignal
  35. }
  36. const DEFAULT_BASE_URL = 'https://api.public.ninety.io/v1'
  37. /** Retry jitter, so parallel workers do not resynchronize. */
  38. const JITTER_MS = 250
  39. export class HttpClient {
  40. private readonly token: string
  41. private readonly baseUrl: string
  42. private readonly limiter: TokenBucket
  43. private readonly maxRetries: number
  44. private readonly fetchImpl: typeof fetch
  45. private readonly sleep: (ms: number) => Promise<void>
  46. private readonly debug: boolean
  47. constructor(opts: HttpOptions) {
  48. if (!opts.token) {
  49. throw new Error(
  50. 'NINETY_TOKEN is missing. Generate a Personal Access Token at ' +
  51. 'Ninety > User Settings > Developer Settings.',
  52. )
  53. }
  54. this.token = opts.token
  55. this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '')
  56. const rps = opts.rps ?? 20
  57. this.limiter = new TokenBucket(rps, rps)
  58. this.maxRetries = opts.maxRetries ?? 3
  59. this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis)
  60. this.sleep = opts.sleep ?? sleep
  61. this.debug = opts.debug ?? false
  62. }
  63. async get<T = unknown>(path: string, opts: RequestOptions = {}): Promise<T> {
  64. return this.request<T>('GET', path, opts)
  65. }
  66. /** Note: Ninety's list endpoints are POST /{resource}/query, not GET. */
  67. async post<T = unknown>(path: string, opts: RequestOptions = {}): Promise<T> {
  68. return this.request<T>('POST', path, opts)
  69. }
  70. async request<T = unknown>(method: string, path: string, opts: RequestOptions = {}): Promise<T> {
  71. const url = this.buildUrl(path, opts.query)
  72. const ctx: NinetyErrorContext = { endpoint: `${method} ${path}` }
  73. if (opts.pageIndex !== undefined) ctx.pageIndex = opts.pageIndex
  74. let lastRetryAfter: number | undefined
  75. for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
  76. await this.limiter.acquire()
  77. if (this.debug) {
  78. console.error(`[ninety] ${method} ${path}${opts.pageIndex !== undefined ? ` page=${opts.pageIndex}` : ''}${attempt > 0 ? ` retry=${attempt}` : ''}`)
  79. }
  80. let res: Response
  81. try {
  82. const init: RequestInit = {
  83. method,
  84. headers: {
  85. Authorization: `Bearer ${this.token}`,
  86. Accept: 'application/json',
  87. ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
  88. },
  89. ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
  90. ...(opts.signal ? { signal: opts.signal } : {}),
  91. }
  92. res = await this.fetchImpl(url, init)
  93. } catch (cause) {
  94. // Network-level failure. Retry, then give up with context.
  95. if (attempt < this.maxRetries) {
  96. await this.sleep(backoffMs(attempt, JITTER_MS))
  97. continue
  98. }
  99. throw new NinetyError(`Network error reaching Ninety: ${String(cause)}`, ctx)
  100. }
  101. if (res.ok) {
  102. if (res.status === 204) return undefined as T
  103. try {
  104. return (await res.json()) as T
  105. } catch (cause) {
  106. throw new NinetyError(`Ninety returned a non-JSON body: ${String(cause)}`, ctx, res.status)
  107. }
  108. }
  109. // 429 and 5xx are retryable. Everything else is not.
  110. if (res.status === 429) {
  111. lastRetryAfter = parseRetryAfter(res.headers.get('Retry-After'))
  112. if (attempt < this.maxRetries) {
  113. await this.sleep(lastRetryAfter ?? backoffMs(attempt, JITTER_MS))
  114. continue
  115. }
  116. throw new NinetyRateLimitError(ctx, lastRetryAfter)
  117. }
  118. if (res.status >= 500 && attempt < this.maxRetries) {
  119. await this.sleep(backoffMs(attempt, JITTER_MS))
  120. continue
  121. }
  122. ctx.body = truncate(await safeText(res), 500)
  123. if (res.status === 401) throw new NinetyAuthError(ctx)
  124. if (res.status === 403) throw new NinetyForbiddenError(ctx)
  125. throw new NinetyError(`Ninety returned ${res.status} ${res.statusText}`, ctx, res.status)
  126. }
  127. // Unreachable: every path above either returns or throws.
  128. throw new NinetyError('Exhausted retries', ctx)
  129. }
  130. private buildUrl(path: string, query?: RequestOptions['query']): string {
  131. const url = new URL(`${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`)
  132. if (query) {
  133. for (const [k, v] of Object.entries(query)) {
  134. if (v !== undefined) url.searchParams.set(k, String(v))
  135. }
  136. }
  137. return url.toString()
  138. }
  139. }
  140. function parseRetryAfter(header: string | null): number | undefined {
  141. if (!header) return undefined
  142. const seconds = Number(header)
  143. if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
  144. const date = Date.parse(header)
  145. if (Number.isFinite(date)) return Math.max(0, date - Date.now())
  146. return undefined
  147. }
  148. async function safeText(res: Response): Promise<string> {
  149. try {
  150. return await res.text()
  151. } catch {
  152. return '<unreadable body>'
  153. }
  154. }
  155. function truncate(s: string, n: number): string {
  156. return s.length <= n ? s : `${s.slice(0, n)}...`
  157. }