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

http.ts

Language TypeScript6.6 KB186 lines

packages/bitrix-client/src/http.ts

  1. /**
  2. * Bitrix24 REST client.
  3. *
  4. * Bitrix has TWO independent limits with inverted-looking error codes. Getting
  5. * them confused means retrying the wrong way, so they are handled separately
  6. * here and nowhere else.
  7. *
  8. * 503 + QUERY_LIMIT_EXCEEDED request rate (leaky bucket)
  9. * -> global backoff, slow down everything
  10. * 429 + OPERATION_TIME_LIMIT accumulated execution time for ONE method
  11. * -> suspend that method only, until operating_reset_at
  12. *
  13. * Note 503 is the rate limit and 429 is not. Ninety uses those codes the
  14. * other way round, so its status handling must not be copied here.
  15. */
  16. import { TokenBucket, backoffMs, sleep } from '@meridian/shared'
  17. export interface BitrixOptions {
  18. /** Inbound webhook base, e.g. https://acme.bitrix24.com/rest/1/abc123/ */
  19. webhookUrl: string
  20. /**
  21. * Requests per second. Tariff allows 2/s sustained (5 on Enterprise) with a
  22. * burst of 50. Default matches the house 7-per-8-seconds used by the Bitrix
  23. * MCP connector: deliberately conservative, and low enough that the cron and
  24. * the MCP server together stay under the shared quota.
  25. */
  26. rps?: number
  27. /** Burst capacity. Tariff allows 50; we stay well under. */
  28. burst?: number
  29. maxRetries?: number
  30. fetchImpl?: typeof fetch
  31. sleep?: (ms: number) => Promise<void>
  32. debug?: boolean
  33. }
  34. export interface BitrixResponse<T> {
  35. result: T
  36. total?: number
  37. next?: number
  38. time?: { operating?: number; operating_reset_at?: number }
  39. }
  40. export class BitrixError extends Error {
  41. constructor(
  42. message: string,
  43. readonly code: string | null,
  44. readonly status: number | undefined,
  45. readonly method: string,
  46. ) {
  47. super(`${message} [${method}]`)
  48. this.name = 'BitrixError'
  49. }
  50. }
  51. export class BitrixRateLimitError extends BitrixError {
  52. constructor(method: string) {
  53. super('Request rate limit (503 QUERY_LIMIT_EXCEEDED). Slow down.', 'QUERY_LIMIT_EXCEEDED', 503, method)
  54. this.name = 'BitrixRateLimitError'
  55. }
  56. }
  57. export class BitrixOperationTimeError extends BitrixError {
  58. constructor(method: string, readonly resetAt: number | null) {
  59. super(
  60. `Method execution-time limit (429 OPERATION_TIME_LIMIT). This method is suspended; ` +
  61. `other methods still work.${resetAt ? ` Retry after ${new Date(resetAt * 1000).toLocaleString('en-US', { timeZone: 'America/Chicago' })} CT.` : ''}`,
  62. 'OPERATION_TIME_LIMIT',
  63. 429,
  64. method,
  65. )
  66. this.name = 'BitrixOperationTimeError'
  67. }
  68. }
  69. export class BitrixClient {
  70. private readonly base: string
  71. private readonly limiter: TokenBucket
  72. private readonly maxRetries: number
  73. private readonly fetchImpl: typeof fetch
  74. private readonly sleep: (ms: number) => Promise<void>
  75. private readonly debug: boolean
  76. /** method -> unix seconds until which it is suspended by OPERATION_TIME_LIMIT. */
  77. private readonly suspended = new Map<string, number>()
  78. /** Last reported accumulated execution time, for proactive backoff. */
  79. lastOperating = 0
  80. constructor(opts: BitrixOptions) {
  81. if (!opts.webhookUrl) {
  82. throw new Error('BITRIX_WEBHOOK_URL is not set. Expected https://<domain>/rest/<user>/<key>/')
  83. }
  84. this.base = opts.webhookUrl.endsWith('/') ? opts.webhookUrl : `${opts.webhookUrl}/`
  85. const rps = opts.rps ?? 7 / 8
  86. this.limiter = new TokenBucket(opts.burst ?? 7, rps)
  87. this.maxRetries = opts.maxRetries ?? 3
  88. this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis)
  89. this.sleep = opts.sleep ?? sleep
  90. this.debug = opts.debug ?? false
  91. }
  92. async call<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<BitrixResponse<T>> {
  93. const until = this.suspended.get(method)
  94. if (until && Date.now() / 1000 < until) {
  95. throw new BitrixOperationTimeError(method, until)
  96. }
  97. for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
  98. await this.limiter.acquire()
  99. if (this.debug) console.error(`[bitrix] ${method}${attempt ? ` retry=${attempt}` : ''}`)
  100. const res = await this.fetchImpl(`${this.base}${method}.json`, {
  101. method: 'POST',
  102. headers: { 'Content-Type': 'application/json' },
  103. body: JSON.stringify(params),
  104. })
  105. let body: unknown
  106. try {
  107. body = await res.json()
  108. } catch {
  109. throw new BitrixError(`Non-JSON response (${res.status})`, null, res.status, method)
  110. }
  111. const err = body as { error?: string; error_description?: string }
  112. // 503: request-rate limit. Global problem; back off and retry.
  113. if (res.status === 503 || err.error === 'QUERY_LIMIT_EXCEEDED') {
  114. if (attempt < this.maxRetries) {
  115. await this.sleep(backoffMs(attempt))
  116. continue
  117. }
  118. throw new BitrixRateLimitError(method)
  119. }
  120. // 429: execution-time limit for THIS method. Retrying now cannot help.
  121. if (res.status === 429 || err.error === 'OPERATION_TIME_LIMIT') {
  122. const t = body as BitrixResponse<never>
  123. const resetAt = t.time?.operating_reset_at ?? null
  124. if (resetAt) this.suspended.set(method, resetAt)
  125. throw new BitrixOperationTimeError(method, resetAt)
  126. }
  127. if (err.error) {
  128. throw new BitrixError(err.error_description ?? err.error, err.error, res.status, method)
  129. }
  130. const ok = body as BitrixResponse<T>
  131. if (typeof ok.time?.operating === 'number') this.lastOperating = ok.time.operating
  132. return ok
  133. }
  134. throw new BitrixRateLimitError(method)
  135. }
  136. /**
  137. * Page a *.list method to completion, or to the cap.
  138. *
  139. * Uses `start` offsets. The open-lead count is far larger than the working
  140. * pipeline suggests: the email-to-lead automation turns security alerts,
  141. * call logs and website chats into leads that nobody closes, so a sweep can
  142. * run past the page cap.
  143. *
  144. * Hitting the cap does not throw. Throwing would discard every row already
  145. * fetched and render the whole source as failed; truncated and labelled
  146. * beats absent (rule 5). The caller must check `truncated` and say so, since
  147. * a silently capped list is a count that is quietly wrong.
  148. */
  149. async listAll<T = unknown>(
  150. method: string,
  151. params: Record<string, unknown> = {},
  152. maxPages = 50,
  153. ): Promise<{ items: T[]; truncated: boolean; pages: number }> {
  154. const out: T[] = []
  155. let start = 0
  156. for (let i = 0; i < maxPages; i++) {
  157. const page = await this.call<T[]>(method, { ...params, start })
  158. const items = Array.isArray(page.result) ? page.result : []
  159. out.push(...items)
  160. if (page.next === undefined || items.length === 0) {
  161. return { items: out, truncated: false, pages: i + 1 }
  162. }
  163. start = page.next
  164. }
  165. return { items: out, truncated: true, pages: maxPages }
  166. }
  167. }