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

http.ts

Language TypeScript4.0 KB118 lines

packages/plinth-client/src/http.ts

  1. /**
  2. * Plinth HTTP layer.
  3. *
  4. * Single endpoint, method dispatched by query parameter, static token. Every
  5. * guard from hazards.ts is applied here so no call site can bypass one:
  6. *
  7. * - method allowlist (a GET with method=orders.setStatus would write and email customers)
  8. * - param allowlist (unknown params are dropped and return unfiltered results)
  9. * - 9-per-10s throttle (documented limit is 10/10s; one request of headroom)
  10. * - ambiguous errors ("Invalid request" means no rows or bad request; never retry it)
  11. */
  12. import { TokenBucket, sleep } from '@meridian/shared'
  13. import {
  14. assertKnownParams,
  15. assertReadMethod,
  16. isEmptyOrInvalid,
  17. readError,
  18. type ReadMethod,
  19. } from './hazards.js'
  20. import { PLINTH_BASE_URL, PLINTH_RATE_LIMIT } from './constants.js'
  21. export interface PlinthOptions {
  22. token: string
  23. baseUrl?: string
  24. maxRetries?: number
  25. fetchImpl?: typeof fetch
  26. sleep?: (ms: number) => Promise<void>
  27. debug?: boolean
  28. }
  29. export class PlinthError extends Error {
  30. constructor(message: string, readonly method: string, readonly status?: number) {
  31. super(`${message} [${method}]`)
  32. this.name = 'PlinthError'
  33. }
  34. }
  35. /** Rate limiting is transient here and should be retried, unlike "Invalid request". */
  36. export class PlinthRateLimitError extends PlinthError {
  37. constructor(method: string) {
  38. super('Plinth rate limit exceeded. Throttle is 9 per 10 seconds; something is calling faster.', method, 429)
  39. this.name = 'PlinthRateLimitError'
  40. }
  41. }
  42. export class PlinthClient {
  43. private readonly token: string
  44. private readonly baseUrl: string
  45. private readonly limiter: TokenBucket
  46. private readonly maxRetries: number
  47. private readonly fetchImpl: typeof fetch
  48. private readonly sleep: (ms: number) => Promise<void>
  49. private readonly debug: boolean
  50. constructor(opts: PlinthOptions) {
  51. if (!opts.token) throw new Error('PLINTH_API_KEY is not set.')
  52. this.token = opts.token
  53. this.baseUrl = opts.baseUrl ?? PLINTH_BASE_URL
  54. this.limiter = new TokenBucket(PLINTH_RATE_LIMIT.capacity, PLINTH_RATE_LIMIT.refillPerSecond)
  55. this.maxRetries = opts.maxRetries ?? 3
  56. this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis)
  57. this.sleep = opts.sleep ?? sleep
  58. this.debug = opts.debug ?? false
  59. }
  60. /**
  61. * Returns the parsed body, or null when Plinth reports the ambiguous
  62. * "Invalid request", which means no rows, a bad request, or both, and cannot be
  63. * told apart. Callers treat null as empty and must not retry.
  64. */
  65. async call<T = unknown>(
  66. method: ReadMethod,
  67. params: Record<string, string | number | undefined> = {},
  68. allowedParams: readonly string[] = [],
  69. ): Promise<T | null> {
  70. assertReadMethod(method)
  71. if (allowedParams.length) assertKnownParams(method, params, allowedParams)
  72. const url = new URL(this.baseUrl)
  73. url.searchParams.set('api_key', this.token)
  74. url.searchParams.set('method', method)
  75. for (const [k, v] of Object.entries(params)) {
  76. if (v !== undefined) url.searchParams.set(k, String(v))
  77. }
  78. for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
  79. await this.limiter.acquire()
  80. if (this.debug) console.error(`[plinth] ${method}${attempt ? ` retry=${attempt}` : ''}`)
  81. const res = await this.fetchImpl(url.toString(), { headers: { Accept: 'application/json' } })
  82. let body: unknown
  83. try {
  84. body = await res.json()
  85. } catch {
  86. throw new PlinthError(`Non-JSON response (${res.status})`, method, res.status)
  87. }
  88. const err = readError(body)
  89. if (err?.startsWith('Too many requests')) {
  90. if (attempt < this.maxRetries) {
  91. await this.sleep(10_000)
  92. continue
  93. }
  94. throw new PlinthRateLimitError(method)
  95. }
  96. // Ambiguous by design. Never retried: a retry cannot turn "no rows" into rows.
  97. if (isEmptyOrInvalid(body)) return null
  98. if (err) throw new PlinthError(err, method, res.status)
  99. return body as T
  100. }
  101. throw new PlinthRateLimitError(method)
  102. }
  103. }