{"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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"}