@bitrix24/b24jssdk
Version:
Bitrix24 REST API JavaScript SDK
1 lines • 14.4 kB
Source Map (JSON)
{"version":3,"file":"operating-limiter.mjs","sources":["../../../../../src/core/http/limiters/operating-limiter.ts"],"sourcesContent":["import type { OperatingLimitConfig, ILimiter } from '../../../types/limiters'\nimport type { PayloadTime } from '../../../types/payloads'\nimport type { LoggerInterface } from '../../../types/logger'\nimport { LoggerFactory } from '../../../logger'\n\ninterface OperatingStats {\n /*\n * operating time in 10 minutes (in ms)\n */\n operating: number\n /**\n * reset time (timestamp in ms)\n */\n operating_reset_at: number\n lastUpdated: number\n}\n\n/**\n * Enforces the Bitrix24 per-method operating-time budget.\n *\n * Bitrix24 charges each REST call against a rolling 10-minute CPU-time\n * quota (`operating` field in the response). This limiter tracks that\n * quota per method and blocks further calls (via {@link ILimiter.canProceed})\n * until the reset timestamp has passed, preventing the `OPERATION_TIME_LIMIT`\n * refusal (HTTP 429) that heavy requests earn by exhausting that budget.\n * `QUERY_LIMIT_EXCEEDED` is a different limit — requests per second — and\n * belongs to {@link RateLimiter}.\n *\n * **It is inert on a default self-hosted portal, and that is the correct\n * behaviour, not a gap.** Such a portal sends no counters, because its own\n * `LoadLimiter` is switched off — and the same switch gates enforcement, so it\n * is not refusing calls either. There is no budget to track. `RateLimiter` and\n * `AdaptiveDelayer` are unaffected.\n *\n * @see https://bitrix24.github.io/b24jssdk/docs/working-with-the-rest-api/limiters/#enabling-the-operating-limiter-on-a-self-hosted-portal\n * for how a box owner switches the portal's limiter on, including the\n * half-configured state in which the counters arrive but never accumulate.\n */\nexport class OperatingLimiter implements ILimiter {\n #config: OperatingLimitConfig\n #methodStats = new Map<string, OperatingStats>()\n #stats = {\n /** Heavy requests */\n heavyRequestCount: 0\n }\n\n private _logger: LoggerInterface\n\n getTitle(): string {\n return 'operatingLimiter'\n }\n\n constructor(config: OperatingLimitConfig) {\n this._logger = LoggerFactory.createNullLogger()\n this.#config = config\n }\n\n // region Logger ////\n setLogger(logger: LoggerInterface): void {\n this._logger = logger\n }\n\n getLogger(): LoggerInterface {\n return this._logger\n }\n // endregion ////\n\n get limitMs(): number {\n return this.#config.limitMs\n }\n\n getMethodStat(method: string): undefined | OperatingStats {\n const stats = this.#methodStats.get(method)\n if (!stats) {\n return undefined\n }\n\n return stats\n }\n\n async canProceed(requestId: string, method: string, params?: any): Promise<boolean> {\n const timeToFree = await this.getTimeToFree(requestId, method, params)\n return timeToFree === 0\n }\n\n async waitIfNeeded(requestId: string, method: string, params?: any): Promise<number> {\n return this.getTimeToFree(requestId, method, params)\n }\n\n /**\n * Returns the time until the method's operating limit is released (in ms)\n * The analysis is based on the previous function call.\n * It's important to understand that we're talking about locks of up to 10 minutes.\n * This is a fairly strict lock based on the limit:\n * - not reached - no lock\n * - reached - lock until the unlock time + 1 second\n *\n * `_requestId` and `_params` went unused when the batch budget started being\n * read under its own `batch` key: the special case that recursed per\n * sub-command was what needed them. They stay in the signature because\n * `RestrictionManager.getTimeToFree` and the public `Http.getTimeToFree`\n * forward all four arguments through.\n */\n async getTimeToFree(\n _requestId: string,\n method: string,\n _params?: any,\n _error?: any\n ): Promise<number> {\n this.#cleanupOldStats()\n\n // `batch` is read here like any other method, and that is the point.\n //\n // The portal keys its operating budget on the triple (auth type, credential,\n // method) and charges a batch to the method `batch` — measured on a live\n // portal, where `batch` stood at 1.228 while `tasks.task.list` read 0 at the\n // same moment, on both API versions. So the budget a batch spends against is\n // the one stored under `batch`, which `_createAjaxResultFromResponse` already\n // records from the envelope.\n //\n // This used to route to a helper that took the largest wait across synthetic\n // `batch::<method>` entries instead. Those modelled a per-sub-method batch budget the portal\n // does not keep, and on v2 they were fed each sub-result's `time` — which\n // carries the batch-wide running sum, identical across all fifty rows, not\n // that command's own cost. The real `batch` entry was written on every call\n // and never read. (#459)\n const stats = this.#methodStats.get(method)\n if (!stats) {\n return 0\n }\n\n // Use limit with buffer. When calculating the operating limit, we will take 5 seconds less\n const limitWithBuffer = Math.max(1_000, this.#config.limitMs - 5_000)\n if (stats.operating >= limitWithBuffer) {\n const now = Date.now()\n if (stats.operating_reset_at > now) {\n // Return the time before reset_at + 1 second\n return (stats.operating_reset_at - now) + 1_000\n }\n return 5_000 // 5 seconds by default\n }\n\n return 0\n }\n\n /**\n * Updates operating time statistics for the method.\n *\n * `data` is optional because **a successful response without a `time` block at\n * all is normal**: `rest.documentation.openapi` answers with the OpenAPI\n * document at the top level — no `result` envelope and no `time` — on every\n * portal tried (an on-premise build, a cloud portal and a cloud sandbox).\n * Destructuring the absent block threw `Cannot destructure property\n * 'operating' of 'data' as it is undefined` and turned a fine HTTP 200 into an\n * exception. Same shape as #338, one level over: there the missing key was\n * `result`, here it is `time`.\n *\n * A `time` block that arrives *without* the counters is a second, separate\n * case and was never the crash — the `operating === undefined` check below has\n * always caught it. It is the normal self-hosted state, because the portal's\n * own limiter is off by default there (the `rest` module's\n * `load_limiter_active` option, default `N`, which nothing in the product ever\n * sets), so the counters are missing from every response on such a portal —\n * which is also a portal that is not enforcing anything.\n *\n * No counters are synthesised when the block is absent. A fabricated\n * `operating: 0` is indistinguishable from a real \"nothing consumed yet\" and\n * would make the limiter confidently wrong — on-premise that exact value is\n * what an enabled-but-unconfigured limiter reports.\n */\n async updateStats(requestId: string, method: string, data?: PayloadTime): Promise<void> {\n if (!data) {\n return\n }\n\n this.#cleanupOldStats()\n\n // all in seconds\n const { operating, operating_reset_at } = data\n if (operating === undefined || operating === null) {\n return\n }\n\n if (!this.#methodStats.has(method)) {\n this.#methodStats.set(method, {\n operating: 0,\n operating_reset_at: 0,\n lastUpdated: Date.now()\n })\n }\n\n const stats = this.#methodStats.get(method)!\n\n stats.operating = operating * 1000\n // Held apart from `operating` deliberately. The two counters travel together\n // on every portal seen, but the type no longer promises that, and the old\n // unconditional `operating_reset_at * 1000` would have written `NaN` into\n // the stats if one ever arrived without the other — and `NaN` compares false\n // against every threshold, so the limiter would have stopped waiting rather\n // than failed visibly. Keeping the previous reset point is the safe reading.\n if (operating_reset_at !== undefined) {\n stats.operating_reset_at = operating_reset_at * 1000\n }\n stats.lastUpdated = Date.now()\n\n // Check for heavy requests\n const usagePercent = (stats.operating / this.#config.limitMs) * 100\n if (usagePercent > this.#config.heavyPercent) {\n this.#stats.heavyRequestCount++\n\n // log if close to the limit\n this.#logStat(requestId, method, usagePercent, stats.operating)\n }\n }\n\n /**\n * Clearing outdated operating limit data\n */\n #cleanupOldStats(): void {\n const now = Date.now()\n const maxAge = this.#config.windowMs + 10_000 // 10 seconds extra\n\n for (const [method, stats] of this.#methodStats.entries()) {\n if (now - stats.lastUpdated > maxAge) {\n this.#methodStats.delete(method)\n }\n }\n }\n\n async reset(): Promise<void> {\n this.#methodStats.clear()\n this.#stats = {\n heavyRequestCount: 0\n }\n }\n\n getStats(): {\n heavyRequestCount: number\n operatingStats: { [method: string]: number }\n } {\n const operatingStats: Record<string, number> = {}\n\n for (const [method, stats] of this.#methodStats.entries()) {\n operatingStats[method] = Number.parseFloat((stats.operating / 1000).toFixed(2))\n }\n\n return {\n ...this.#stats,\n operatingStats\n }\n }\n\n async setConfig(config: OperatingLimitConfig): Promise<void> {\n this.#config = config\n }\n\n // region Log ////\n #logStat(requestId: string, method: string, percent: number, operating: number) {\n this.getLogger().debug(`${this.getTitle()} detected limit for method ${method}`, {\n requestId,\n method,\n operating: {\n percent: Number.parseFloat(percent.toFixed(2)),\n current: Number.parseFloat((operating / 1000).toFixed(0)),\n max: Number.parseFloat((this.#config.limitMs / 1000).toFixed(0))\n }\n }).catch(() => {})\n }\n // endregion ////\n}\n"],"names":[],"mappings":"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"}