http.ts
Language TypeScript6.6 KB186 lines
/*** Bitrix24 REST client.** Bitrix has TWO independent limits with inverted-looking error codes. Getting* them confused means retrying the wrong way, so they are handled separately* here and nowhere else.** 503 + QUERY_LIMIT_EXCEEDED request rate (leaky bucket)* -> global backoff, slow down everything* 429 + OPERATION_TIME_LIMIT accumulated execution time for ONE method* -> suspend that method only, until operating_reset_at** Note 503 is the rate limit and 429 is not. Ninety uses those codes the* other way round, so its status handling must not be copied here.*/import { TokenBucket, backoffMs, sleep } from '@meridian/shared'export interface BitrixOptions {/** Inbound webhook base, e.g. https://acme.bitrix24.com/rest/1/abc123/ */webhookUrl: string/*** Requests per second. Tariff allows 2/s sustained (5 on Enterprise) with a* burst of 50. Default matches the house 7-per-8-seconds used by the Bitrix* MCP connector: deliberately conservative, and low enough that the cron and* the MCP server together stay under the shared quota.*/rps?: number/** Burst capacity. Tariff allows 50; we stay well under. */burst?: numbermaxRetries?: numberfetchImpl?: typeof fetchsleep?: (ms: number) => Promise<void>debug?: boolean}export interface BitrixResponse<T> {result: Ttotal?: numbernext?: numbertime?: { operating?: number; operating_reset_at?: number }}export class BitrixError extends Error {constructor(message: string,readonly code: string | null,readonly status: number | undefined,readonly method: string,) {super(`${message} [${method}]`)this.name = 'BitrixError'}}export class BitrixRateLimitError extends BitrixError {constructor(method: string) {super('Request rate limit (503 QUERY_LIMIT_EXCEEDED). Slow down.', 'QUERY_LIMIT_EXCEEDED', 503, method)this.name = 'BitrixRateLimitError'}}export class BitrixOperationTimeError extends BitrixError {constructor(method: string, readonly resetAt: number | null) {super(`Method execution-time limit (429 OPERATION_TIME_LIMIT). This method is suspended; ` +`other methods still work.${resetAt ? ` Retry after ${new Date(resetAt * 1000).toLocaleString('en-US', { timeZone: 'America/Chicago' })} CT.` : ''}`,'OPERATION_TIME_LIMIT',429,method,)this.name = 'BitrixOperationTimeError'}}export class BitrixClient {private readonly base: stringprivate readonly limiter: TokenBucketprivate readonly maxRetries: numberprivate readonly fetchImpl: typeof fetchprivate readonly sleep: (ms: number) => Promise<void>private readonly debug: boolean/** method -> unix seconds until which it is suspended by OPERATION_TIME_LIMIT. */private readonly suspended = new Map<string, number>()/** Last reported accumulated execution time, for proactive backoff. */lastOperating = 0constructor(opts: BitrixOptions) {if (!opts.webhookUrl) {throw new Error('BITRIX_WEBHOOK_URL is not set. Expected https://<domain>/rest/<user>/<key>/')}this.base = opts.webhookUrl.endsWith('/') ? opts.webhookUrl : `${opts.webhookUrl}/`const rps = opts.rps ?? 7 / 8this.limiter = new TokenBucket(opts.burst ?? 7, rps)this.maxRetries = opts.maxRetries ?? 3this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis)this.sleep = opts.sleep ?? sleepthis.debug = opts.debug ?? false}async call<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<BitrixResponse<T>> {const until = this.suspended.get(method)if (until && Date.now() / 1000 < until) {throw new BitrixOperationTimeError(method, until)}for (let attempt = 0; attempt <= this.maxRetries; attempt++) {await this.limiter.acquire()if (this.debug) console.error(`[bitrix] ${method}${attempt ? ` retry=${attempt}` : ''}`)const res = await this.fetchImpl(`${this.base}${method}.json`, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify(params),})let body: unknowntry {body = await res.json()} catch {throw new BitrixError(`Non-JSON response (${res.status})`, null, res.status, method)}const err = body as { error?: string; error_description?: string }// 503: request-rate limit. Global problem; back off and retry.if (res.status === 503 || err.error === 'QUERY_LIMIT_EXCEEDED') {if (attempt < this.maxRetries) {await this.sleep(backoffMs(attempt))continue}throw new BitrixRateLimitError(method)}// 429: execution-time limit for THIS method. Retrying now cannot help.if (res.status === 429 || err.error === 'OPERATION_TIME_LIMIT') {const t = body as BitrixResponse<never>const resetAt = t.time?.operating_reset_at ?? nullif (resetAt) this.suspended.set(method, resetAt)throw new BitrixOperationTimeError(method, resetAt)}if (err.error) {throw new BitrixError(err.error_description ?? err.error, err.error, res.status, method)}const ok = body as BitrixResponse<T>if (typeof ok.time?.operating === 'number') this.lastOperating = ok.time.operatingreturn ok}throw new BitrixRateLimitError(method)}/*** Page a *.list method to completion, or to the cap.** Uses `start` offsets. The open-lead count is far larger than the working* pipeline suggests: the email-to-lead automation turns security alerts,* call logs and website chats into leads that nobody closes, so a sweep can* run past the page cap.** Hitting the cap does not throw. Throwing would discard every row already* fetched and render the whole source as failed; truncated and labelled* beats absent (rule 5). The caller must check `truncated` and say so, since* a silently capped list is a count that is quietly wrong.*/async listAll<T = unknown>(method: string,params: Record<string, unknown> = {},maxPages = 50,): Promise<{ items: T[]; truncated: boolean; pages: number }> {const out: T[] = []let start = 0for (let i = 0; i < maxPages; i++) {const page = await this.call<T[]>(method, { ...params, start })const items = Array.isArray(page.result) ? page.result : []out.push(...items)if (page.next === undefined || items.length === 0) {return { items: out, truncated: false, pages: i + 1 }}start = page.next}return { items: out, truncated: true, pages: maxPages }}}