@bitrix24/b24jssdk
Version:
Bitrix24 REST API JavaScript SDK
558 lines (555 loc) • 21.5 kB
JavaScript
/**
* @package @bitrix24/b24jssdk
* @version 3.0.0
* @copyright (c) 2026 Bitrix24
* @license MIT
* @see https://github.com/bitrix24/b24jssdk
* @see https://bitrix24.github.io/b24jssdk/
*/
import { RateLimiter } from './rate-limiter.mjs';
import { OperatingLimiter } from './operating-limiter.mjs';
import { AdaptiveDelayer } from './adaptive-delayer.mjs';
import { ParamsFactory } from './params-factory.mjs';
import { SdkError } from '../../sdk-error.mjs';
import { LoggerFactory } from '../../../logger/logger-factory.mjs';
var __defProp = Object.defineProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
const RATE_LIMIT_FIELDS = ["burstLimit", "drainRate", "adaptiveEnabled"];
const OPERATING_LIMIT_FIELDS = ["windowMs", "limitMs", "heavyPercent"];
const ADAPTIVE_CONFIG_FIELDS = ["thresholdPercent", "coefficient", "maxDelay", "enabled"];
function definedOnly(params) {
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== void 0)
);
}
__name(definedOnly, "definedOnly");
function cloneParams(params) {
return {
...params,
...params.rateLimit ? { rateLimit: { ...params.rateLimit } } : {},
...params.operatingLimit ? { operatingLimit: { ...params.operatingLimit } } : {},
...params.adaptiveConfig ? { adaptiveConfig: { ...params.adaptiveConfig } } : {},
...params.hardErrorCodes ? { hardErrorCodes: [...params.hardErrorCodes] } : {},
...params.softErrorCodes ? { softErrorCodes: [...params.softErrorCodes] } : {}
};
}
__name(cloneParams, "cloneParams");
function assertBlock(block, name, fields) {
if (block === void 0) {
return;
}
const present = block;
const missing = present === null ? [...fields] : fields.filter((field) => present[field] === void 0);
if (missing.length === 0) {
return;
}
throw new SdkError({
code: "JSSDK_LIMITER_INVALID_CONFIG_BLOCK",
description: `setRestrictionManagerParams: \`${name}\` is replaced whole, not merged, so it must carry every field. Missing: ${missing.join(", ")}. Spread the current block if you mean to change one value.`,
status: 500
});
}
__name(assertBlock, "assertBlock");
class RestrictionManager {
static {
__name(this, "RestrictionManager");
}
#rateLimiter;
#operatingLimiter;
#adaptiveDelayer;
#config;
#stats = {
/** Retry attempts */
retries: 0,
/** Consecutive errors */
consecutiveErrors: 0,
/** Limit triggers */
limitHits: 0
};
#errorCounts = /* @__PURE__ */ new Map();
_logger;
/**
* `RestrictionManager` is exported from the package root and every field of
* `RestrictionParams` is optional, so `new RestrictionManager({ maxRetries: 5 })`
* is a call TypeScript accepts. It used to throw from inside `RateLimiter`,
* because the defaulting lived one class away in `AbstractHttp` and the
* assertions here claimed a caller always sends every block. Merging against
* the defaults makes the class safe on its own terms, and makes the promise
* this file documents true here as well as in the setter. (#479)
*/
constructor(params) {
this._logger = LoggerFactory.createNullLogger();
const patch = definedOnly(params);
assertBlock(patch.rateLimit, "rateLimit", RATE_LIMIT_FIELDS);
assertBlock(patch.operatingLimit, "operatingLimit", OPERATING_LIMIT_FIELDS);
assertBlock(patch.adaptiveConfig, "adaptiveConfig", ADAPTIVE_CONFIG_FIELDS);
this.#config = cloneParams({ ...ParamsFactory.getDefault(), ...patch });
this.#rateLimiter = new RateLimiter(this.#config.rateLimit);
this.#operatingLimiter = new OperatingLimiter(this.#config.operatingLimit);
this.#adaptiveDelayer = new AdaptiveDelayer(this.#config.adaptiveConfig, this.#operatingLimiter);
}
// region Logger ////
setLogger(logger) {
this._logger = logger;
this.#rateLimiter.setLogger(this._logger);
this.#operatingLimiter.setLogger(this._logger);
this.#adaptiveDelayer.setLogger(this._logger);
}
getLogger() {
return this._logger;
}
// endregion ////
async applyOperatingLimits(requestId, method, params) {
const operatingWait = await this.#operatingLimiter.waitIfNeeded(requestId, method, params);
if (operatingWait > 0) {
this.incrementStats("limitHits");
this.#logMethodBlocked(this.#operatingLimiter.getTitle(), requestId, method, operatingWait);
await this.#delay(operatingWait);
} else {
const adaptiveDelay = await this.#adaptiveDelayer.waitIfNeeded(requestId, method, params);
if (adaptiveDelay > 0) {
this.incrementStats("limitHits");
this.#logMethodBlocked(this.#adaptiveDelayer.getTitle(), requestId, method, adaptiveDelay);
await this.#delay(adaptiveDelay);
}
}
}
/**
* Checks and waits for the rate limit
* The loop is needed for parallel requests (Promise.all())
*/
async checkRateLimit(requestId, method) {
let waitTime;
let times = 1;
do {
waitTime = await this.#rateLimiter.waitIfNeeded(requestId, method);
if (waitTime > 0) {
this.incrementStats("limitHits");
this.#logMethodBlockedWithTimes(this.#rateLimiter.getTitle(), requestId, method, waitTime, times);
await this.#delay(waitTime);
times++;
}
} while (waitTime > 0);
}
/**
* Fans the response's `time` block out to all three limiters.
*
* `timeData` is `undefined` when the portal sent no `time` block at all, which
* a success legitimately can — `rest.documentation.openapi` answers with the
* OpenAPI document at the top level. The transport already skips this call in
* that case; the parameter stays optional because `updateStats` is on the
* public {@link ILimiter} contract and the transport is not its only caller.
*/
async updateStats(requestId, method, timeData) {
await this.#operatingLimiter.updateStats(requestId, method, timeData);
await this.#adaptiveDelayer.updateStats(requestId, method, timeData);
await this.#rateLimiter.updateStats(requestId, method, timeData);
}
async handleError(requestId, method, params, error, attempt) {
if (this.#isRateLimitError(error)) {
const wait = await this.#handleRateLimitExceeded(requestId) * Math.pow(1.5, attempt);
this.#logError(this.#rateLimiter.getTitle(), requestId, "QUERY_LIMIT_EXCEEDED", error.message, method, wait);
return wait;
}
if (this.#isOperatingLimitError(error)) {
const wait = Math.max(1e4, await this.#handleOperatingLimitError(requestId, method, params, error));
this.#logError(this.#operatingLimiter.getTitle(), requestId, "OPERATION_TIME_LIMIT", error.message, method, wait);
return wait;
}
if (this.#isNonRetryableClientError(error)) {
this.#logNonRetryableClientError(requestId, error?.code ? `${error.code}` : "?", error?.message ?? "", method, Number(error?.status ?? 0));
return 0;
}
if (!this.#isNeedThrowError(error)) {
const baseDelay = await this.#getErrorBackoff(requestId);
const maxDelay = Math.max(3e4, baseDelay);
const delay = Math.min(maxDelay, baseDelay * Math.pow(2, attempt));
const jitter = delay * 0.1 * (Math.random() * 2 - 1);
const wait = Math.max(100, delay + jitter);
this.#logSomeError(requestId, error?.code ? `${error.code}` : "?", error.message, method, wait);
return wait;
}
return 0;
}
/**
* Checks if the error is a rate limit
*/
#isRateLimitError(error) {
return error.status === 503 || error.code === "QUERY_LIMIT_EXCEEDED";
}
/**
* Delay when exceeding the rate limit
*/
async #handleRateLimitExceeded(requestId) {
return this.#rateLimiter.handleExceeded(requestId);
}
/**
* Checks if the error is an operating limit
*
* @memo `OPERATION_TIME_LIMIT` && `429` - obtained through practical means
* @memo This doesn't work for `batch` queries.
*/
#isOperatingLimitError(error) {
return error.status === 429 || error.code === "OPERATION_TIME_LIMIT";
}
/**
* Operating limit error delay
*
* @memo Currently, the errors don't include timings for operations.
* For this reason, we will take data from the previous request
*/
async #handleOperatingLimitError(requestId, method, params, _error) {
return this.#operatingLimiter.getTimeToFree(requestId, method, params, _error);
}
/**
* Checks if the error is a non-retryable client error (HTTP 4xx).
*
* `429` is excluded — it is handled as a rate/operating limit and is retried
* with backoff. `408` (request timeout) is excluded — it is transient and is
* governed by `retryOnNetworkError`.
*/
#isNonRetryableClientError(error) {
const status = Number(error?.status ?? 0);
if (Number.isNaN(status)) {
return false;
}
return status >= 400 && status < 500 && status !== 408 && status !== 429;
}
/**
* Checks whether attempts should be stopped if errors are encountered that are unclear.
*/
#isNeedThrowError(error) {
const answerError = {
code: error?.code ?? "-1",
description: error?.message ?? ""
};
return [
...this.exceptionCodeForHard,
...this.exceptionCodeForSoft
].includes(answerError.code) || (answerError.description ?? "").includes("Could not find value for parameter");
}
/**
* Built-in hard error codes (always throw, never retry).
*
* Includes authorization and fatal codes that must never be silently retried.
* Use `RestrictionParams.hardErrorCodes` to extend this list with custom codes.
*/
static BUILT_IN_HARD_ERROR_CODES = [
"ERR_BAD_REQUEST",
"JSSDK_UNKNOWN_ERROR",
"100",
"INTERNAL_SERVER_ERROR",
"ERROR_UNEXPECTED_ANSWER",
"PORTAL_DELETED",
"ERROR_BATCH_METHOD_NOT_ALLOWED",
"ERROR_BATCH_LENGTH_EXCEEDED",
"NO_AUTH_FOUND",
"INVALID_REQUEST",
"OVERLOAD_LIMIT",
"expired_token",
"invalid_token",
"ACCESS_DENIED",
"INVALID_CREDENTIALS",
"user_access_error",
"insufficient_scope",
// The `restApi:v3` spelling of `insufficient_scope`, pinned so the same
// condition is delivered the same way on both versions. Without it the v3
// form matches nothing — it is a different string — and the category rule
// would soften it at 403 while the v2 form kept throwing. This is a missing
// OAuth grant, a configuration fault rather than a per-record ACL check, so
// it stays loud; the neighbouring `…ACCESSDENIEDEXCEPTION` is a permission
// check and stays soft. (#460)
"BITRIX_REST_V3_EXCEPTION_INSUFFICIENTSCOPEEXCEPTION",
// A refused redirect is deterministic: the portal will answer the next
// attempt the same way, and every retry re-sends a request carrying an
// access token to a host that wants to redirect it. Measured before this
// line existed: three POSTs of the token-bearing batch before the error
// surfaced, because the code arrives with `status: 0` and so misses the
// non-retryable-4xx branch.
"JSSDK_HTTP_REDIRECT_BLOCKED",
"ERROR_MANIFEST_IS_NOT_AVAILABLE",
"allowed_only_intranet_user",
"NOT_FOUND",
"INVALID_ARG_VALUE"
];
/**
* Built-in soft error codes (returned as `AjaxResult` with error, never thrown).
*
* Use `RestrictionParams.softErrorCodes` to extend this list with custom codes.
*/
static BUILT_IN_SOFT_ERROR_CODES = [
"ERROR_ENTITY_NOT_FOUND",
"BITRIX_REST_V3_EXCEPTION_ACCESSDENIEDEXCEPTION",
"BITRIX_REST_V3_EXCEPTION_INVALIDJSONEXCEPTION",
"BITRIX_REST_V3_EXCEPTION_INVALIDFILTEREXCEPTION",
"BITRIX_REST_V3_EXCEPTION_INVALIDSELECTEXCEPTION",
"BITRIX_REST_V3_EXCEPTION_ENTITYNOTFOUNDEXCEPTION",
"BITRIX_REST_V3_EXCEPTION_METHODNOTFOUNDEXCEPTION",
"BITRIX_REST_V3_EXCEPTION_UNKNOWNDTOPROPERTYEXCEPTION",
"BITRIX_REST_V3_EXCEPTION_VALIDATION_REQUESTVALIDATIONEXCEPTION",
"BITRIX_REST_V3_EXCEPTION_VALIDATION_DTOVALIDATIONEXCEPTION"
];
/**
* Codes that cause the SDK to throw immediately.
*
* Composed of:
* - `BUILT_IN_HARD_ERROR_CODES` (always included)
* - `NETWORK_ERROR` and `REQUEST_TIMEOUT` when `retryOnNetworkError === false`
* - `RestrictionParams.hardErrorCodes` (user-provided extensions)
*/
get exceptionCodeForHard() {
const codes = [...RestrictionManager.BUILT_IN_HARD_ERROR_CODES];
if (this.#config.retryOnNetworkError === false) {
codes.push("NETWORK_ERROR", "REQUEST_TIMEOUT");
}
if (this.#config.hardErrorCodes && this.#config.hardErrorCodes.length > 0) {
codes.push(...this.#config.hardErrorCodes);
}
return codes;
}
/**
* Codes returned as `AjaxResult` with an `AjaxError` payload instead of thrown.
*
* Composed of:
* - `BUILT_IN_SOFT_ERROR_CODES` (always included)
* - `RestrictionParams.softErrorCodes` (user-provided extensions)
*/
get exceptionCodeForSoft() {
const codes = [...RestrictionManager.BUILT_IN_SOFT_ERROR_CODES];
if (this.#config.softErrorCodes && this.#config.softErrorCodes.length > 0) {
codes.push(...this.#config.softErrorCodes);
}
return codes;
}
/**
* Statuses a 4xx category rule must not claim.
*
* `401` belongs to the auth-refresh path, which owns it end to end. `408`
* and `429` are the two retryable 4xx — the same pair
* `#isNonRetryableClientError` excludes — and an error still being retried
* has not been classified yet.
*
* `403` is deliberately **not** here. A permission or scope refusal is
* caller-addressable, and `…_ACCESSDENIEDEXCEPTION` is already in the
* built-in soft list, so excluding 403 would leave one 403 soft by list and
* its neighbour thrown — exactly the per-code arbitrariness this rule
* removes. (#460)
*/
static #CATEGORY_RULE_EXCLUDED_STATUSES = [401, 408, 429];
/**
* Should this error reach the caller inside an `AjaxResult` rather than be
* thrown?
*
* Evaluated in a fixed order, most specific first:
*
* 1. a code in `exceptionCodeForHard` throws — so the rule can never soften
* a credential failure, and a caller's `hardErrorCodes` always wins;
* 2. a code in `exceptionCodeForSoft` is soft — so the built-in list, the v2
* codes and a caller's `softErrorCodes` keep working unchanged;
* 3. an error that arrived in the **v3 error envelope** carrying a **4xx
* other than 401 / 408 / 429** is soft, whatever its code;
* 4. otherwise it throws.
*
* Step 3 was opt-in through the 2.x line, behind `classifyV3ErrorsByCategory`,
* and is simply the behaviour as of 3.0.0 — the flag is gone rather than
* inverted, because a permanent compatibility switch is what the 2.x
* arrangement was scheduled to avoid becoming (#480). A caller who needs the
* old classification for one code pins it in `hardErrorCodes`, which step 1
* still honours.
*
* Step 3 keys on the envelope the response actually carried, never on the
* client's own version: a gateway in front of the v3 controller is documented
* to sometimes answer in the flat v2 shape, and such a body is left to the
* lists.
*/
isSoftError(error) {
const code = error?.code;
const codeText = typeof code === "string" ? code : "";
if (this.exceptionCodeForHard.includes(codeText)) {
return false;
}
if (this.exceptionCodeForSoft.includes(codeText)) {
return true;
}
if (error?.isV3Envelope !== true) {
return false;
}
const status = Number(error?.status ?? 0);
return Number.isInteger(status) && status >= 400 && status < 500 && !RestrictionManager.#CATEGORY_RULE_EXCLUDED_STATUSES.includes(status);
}
/**
* Delay due to unknown errors
*/
async #getErrorBackoff(_requestId) {
return this.#config.retryDelay ?? ParamsFactory.getDefault().retryDelay;
}
incrementError(method) {
const current = this.#errorCounts.get(method) || 0;
this.#errorCounts.set(method, current + 1);
this.incrementStats("consecutiveErrors");
}
resetErrors(method) {
this.#errorCounts.delete(method);
this.#stats.consecutiveErrors = 0;
}
incrementStats(stat) {
this.#stats[stat]++;
}
/**
* Returns job statistics
*/
getStats() {
return {
...this.#stats,
...this.#rateLimiter.getStats(),
...this.#adaptiveDelayer.getStats(),
...this.#operatingLimiter.getStats(),
errorCounts: Object.fromEntries(this.#errorCounts)
};
}
/**
* Resets limiters and statistics
*/
async reset() {
await this.#rateLimiter.reset();
await this.#operatingLimiter.reset();
await this.#adaptiveDelayer.reset();
this.#errorCounts.clear();
this.#stats = {
retries: 0,
consecutiveErrors: 0,
limitHits: 0
};
}
/**
* Replaces the named parameters, keeping the ones not mentioned.
*
* It used to assign — `this.#config = params` — so a caller changing one
* field silently lost every other one. `setRestrictionManagerParams({
* maxRetries: 5 })` after a careful setup left `hardErrorCodes`,
* `softErrorCodes`, `retryOnNetworkError` **and** `rateLimit` all
* `undefined`, with no error and nothing in the log. (#479)
*
* The tell was in our own documentation: every example of this method spreads
* `...ParamsFactory.getDefault()` first. That was not house style, it was a
* workaround repeated everywhere the method appeared.
*
* **The merge is shallow.** `rateLimit`, `operatingLimit` and
* `adaptiveConfig` are replaced **whole**, not merged field by field — pass
* one and you supply all of its fields, omit it and it is left untouched.
* Deep-merging them would let a half-specified `rateLimit` combine with an
* older one into a pair of numbers nobody chose; replacing keeps a limiter's
* configuration something a caller stated in one place.
*
* A sub-limiter is only reconfigured when its own block was supplied, which
* is also why the non-null assertions here are gone: they claimed the caller
* always sends every block, and the whole point of this method is that they
* do not. A block that *is* supplied must be whole — `assertBlock` refuses a
* partial one rather than letting `1000 / undefined` switch rate limiting off
* for the rest of the process.
*
* An **explicitly `undefined`** value counts as "not mentioned". Object
* spread would otherwise copy the key and erase the field, which is not what
* `maxRetries: enabled ? 5 : undefined` means.
*/
async setConfig(params) {
const patch = definedOnly(params);
assertBlock(patch.rateLimit, "rateLimit", RATE_LIMIT_FIELDS);
assertBlock(patch.operatingLimit, "operatingLimit", OPERATING_LIMIT_FIELDS);
assertBlock(patch.adaptiveConfig, "adaptiveConfig", ADAPTIVE_CONFIG_FIELDS);
this.#config = cloneParams({ ...this.#config, ...patch });
if (patch.rateLimit !== void 0) {
await this.#rateLimiter.setConfig({ ...patch.rateLimit });
}
if (patch.operatingLimit !== void 0) {
await this.#operatingLimiter.setConfig({ ...patch.operatingLimit });
}
if (patch.adaptiveConfig !== void 0) {
await this.#adaptiveDelayer.setConfig({ ...patch.adaptiveConfig });
}
}
/**
* A **copy**, nested blocks and code arrays included.
*
* A shallow spread handed the caller the very objects the sub-limiters use:
* `RateLimiter` rewrites `drainRate` and `burstLimit` in place while it
* throttles adaptively, so a caller reading the policy saw it change under
* them, and a caller pushing onto the returned `hardErrorCodes` reconfigured
* the manager without ever calling the setter. (#479)
*/
getParams() {
return cloneParams(this.#config);
}
/**
* Delay function
*/
async #delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Public access to the delay function
*/
async waiteDelay(ms) {
return this.#delay(ms);
}
// region Log ////
#logMethodBlocked(limiter, requestId, method, wait) {
this.getLogger().notice(`${limiter} blocked method ${method}`, {
requestId,
method,
wait,
limiter
}).catch(() => {
});
}
#logMethodBlockedWithTimes(limiter, requestId, method, wait, times) {
this.getLogger().notice(`${limiter} blocked method ${method} | ${times} times`, {
requestId,
method,
times,
wait,
limiter
}).catch(() => {
});
}
#logError(limiter, requestId, code, message, method, wait) {
this.getLogger().error(`${limiter} recognized the ${code} error for the ${method} method`, {
requestId,
method,
wait,
limiter,
error: {
code,
message
}
}).catch(() => {
});
}
#logSomeError(requestId, code, message, method, wait) {
this.getLogger().error(`recognized the ${code} error for the ${method} method`, {
requestId,
method,
wait,
error: {
code,
message
}
}).catch(() => {
});
}
#logNonRetryableClientError(requestId, code, message, method, status) {
this.getLogger().error(`client error ${status} (${code}) for the ${method} method is not retryable`, {
requestId,
method,
status,
error: {
code,
message
}
}).catch(() => {
});
}
// endregion ////
}
export { RestrictionManager };
//# sourceMappingURL=manager.mjs.map