Overview
The system consists of several components working together to prevent exceeding API limits and ensure stable operation.
┌─────────────────────────────────────────────┐
│ RestrictionManager │
│ (Coordinator of all types of restrictions) │
└─────┬──────────────┬──────────────┬─────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌────────────┐ ┌─────────────┐
│ Rate │ │ Operating │ │ Adaptive │
│ Limiter │ │ Limiter │ │ Delayer │
└──────────┘ └────────────┘ └─────────────┘
Interfaces and Types
ILimiter (Base Interface)
interface ILimiter {
getTitle(): string
setConfig(config: any): Promise<void>
setLogger(logger: LoggerInterface): void
getLogger(): LoggerInterface
canProceed(requestId: string, method: string, params?: any): Promise<boolean>
waitIfNeeded(requestId: string, method: string, params?: any): Promise<number>
updateStats(requestId: string, method: string, data: any): Promise<void>
reset(): Promise<void>
getStats(): Record<string, any>
}
RestrictionParams
interface RestrictionParams {
rateLimit?: RateLimitConfig // Rate limiting settings
operatingLimit?: OperatingLimitConfig // Operation time limit settings
adaptiveConfig?: AdaptiveConfig // Adaptive delay settings
maxRetries?: number // Maximum number of request retries (default: 3). Except for `batch` requests
retryDelay?: number // Base delay between retries in ms (default: 1000)
retryOnNetworkError?: boolean // Retry on `NETWORK_ERROR` / `REQUEST_TIMEOUT` (HTTP 408) (default: true).
// Set to `false` for non-idempotent calls — see "Non-idempotent calls" below.
hardErrorCodes?: string[] // Extra codes to throw immediately (merged with built-in hard list).
softErrorCodes?: string[] // Extra codes to return as AjaxResult with error (merged with built-in soft list).
}
System Components
1. RateLimiter (Rate Limiting)
Purpose: Implements the "Leaky Bucket" algorithm for limiting request frequency.
RateLimiter — Configuration
interface RateLimitConfig {
burstLimit: number // Bucket capacity (maximum number of simultaneous requests)
drainRate: number // Leak rate (requests per second)
adaptiveEnabled: boolean // Enable adaptive management
}
RateLimiter — Operation Principle
- Token-based system: Each request consumes one token
- Automatic replenishment: Tokens are replenished at the rate of
drainRate - Adaptive management:
- With frequent errors, limits are automatically reduced by 20%
- With stable operation, limits are gradually restored
- Minimum values:
drainRate = 0.5,burstLimit = 5
2. OperatingLimiter (Operation Time Limiting)
Purpose: Controls total operation execution time within a sliding window.
OperatingLimiter — Configuration
interface OperatingLimitConfig {
windowMs: number // Time period in milliseconds (default: 600000 = 10 minutes)
limitMs: number // Maximum execution time in milliseconds (default: 480000 = 480 seconds)
heavyPercent: number // Threshold for heavy request notifications (%)
}
OperatingLimiter — Operation Principle
- Sliding window: Tracks execution time over the last 10 minutes
- Method-specific tracking: Statistics are kept separately for each method
- Safety buffer: Calculations use
limitMs - 5000(5 second buffer) - Blocking: When the limit is reached, blocks execution until statistics are reset
OperatingLimiter — Features
- For batch requests, analyzes all nested methods
- Automatic cleanup of outdated data (> windowMs + 10 seconds)
- Logging of heavy requests when exceeding
heavyPercent
operating / operating_reset_at counters in the response's time block, and the portal sends them only when its own operating limiter is active — on-premise that reads the rest module option load_limiter_active, whose default is N and which nothing in the product ever sets. Without the counters the SDK skips the bookkeeping rather than invent a 0, so OperatingLimiter neither delays nor reports anything. RateLimiter and AdaptiveDelayer are unaffected. If you need operating-time throttling on-premise, the administrator has to enable the option and configure load_limiter_connection_name — enabled without a connection, the counter is a constant zero, which is worse than missing.3. AdaptiveDelayer (Adaptive Delays)
Purpose: Dynamically calculates delays based on previous request execution experience.
AdaptiveDelayer — Configuration
interface AdaptiveConfig {
enabled: boolean // Enable adaptive delays
thresholdPercent: number // Activation threshold (% of operating limit)
coefficient: number // Delay multiplier (0.01 = 1% of remaining blocking time)
maxDelay: number // Maximum delay in milliseconds
}
AdaptiveDelayer — Delay Calculation Algorithm
If operating of current method > (limitMs × thresholdPercent / 100):
If operating_reset_at > current time:
Delay = (operating_reset_at - current time) × coefficient
Otherwise:
Delay = 7000 ms (default value)
Final delay = min(calculated, maxDelay)
AdaptiveDelayer — Features
- For batch requests, selects the maximum delay among all methods
- Does not block execution, only adds delay
- Uses statistics from OperatingLimiter
4. RestrictionManager (Main Coordinator)
Purpose: Manages all types of restrictions and error handling.
Order of Applying Restrictions
- Operating Limit Check: Checks operation time limit
- Adaptive Delay: Applies adaptive delay if necessary
- Rate Limit: Checks and applies rate limiting (loop for parallel requests)
Error Handling
// Determining error type
#isRateLimitError(error): boolean // 503 or QUERY_LIMIT_EXCEEDED
#isOperatingLimitError(error): boolean // 429 or OPERATION_TIME_LIMIT
#isNonRetryableClientError(error): boolean // HTTP 4xx (except 429 / 408)
#isNeedThrowError(error): boolean // Critical errors (no point in retrying)
Retry Strategy
- For limit errors: Exponential delay considering the attempt number
- For client errors (HTTP 4xx, except 429 / 408): No retries — a
4xxresponse is deterministic, so retrying cannot change the outcome. The error exits the retry loop on the first attempt; the usual hard/soft classification then applies (thrown asAjaxError, or returned insideAjaxResultfor soft codes).429is retried as a rate/operating limit;408(request timeout) stays transient. - For other errors: Basic backoff with jitter (±10%)
- Critical errors: No retries
Updating the parameters
setRestrictionManagerParams replaces the parameters you name and keeps the
rest. Call it twice and the second call does not undo the first:
import { ParamsFactory } from '@bitrix24/b24jssdk'
// const $b24 = ...
await $b24.setRestrictionManagerParams({
...ParamsFactory.getDefault(),
retryOnNetworkError: false,
hardErrorCodes: ['MY_APP_BAD_PAYLOAD']
})
// Later, elsewhere. `retryOnNetworkError` and `hardErrorCodes` survive.
await $b24.setRestrictionManagerParams({ maxRetries: 5 })
rateLimit,
operatingLimit and adaptiveConfig are not merged field by field — supply
one and you supply all of its fields; omit it and it is left untouched.TypeScript enforces this for you: RateLimitConfig and its siblings have no
optional fields, so { rateLimit: { burstLimit: 7 } } is a compile error. From
JavaScript, or behind a cast, it is refused at runtime instead —
JSSDK_LIMITER_INVALID_CONFIG_BLOCK, naming the fields you left out. Nothing
half-built reaches a limiter.Deep-merging would let a half-specified block combine with an older one into a
pair of numbers nobody chose. To change one field, spread the current block:import { ApiVersion, ParamsFactory } from '@bitrix24/b24jssdk'
// const $b24 = ...
const client = $b24.getHttpClient(ApiVersion.v3)
const current = client.getRestrictionManagerParams()
// `$b24.setRestrictionManagerParams` writes to EVERY API version, while the
// block above came from one client. Go through the same client when you mean
// to change only that one.
await client.setRestrictionManagerParams({
...current,
rateLimit: { ...(current.rateLimit ?? ParamsFactory.getDefault().rateLimit!), burstLimit: 7 }
})
getRestrictionManagerParams() returns a copy, nested blocks included, so
what you read is a snapshot rather than the limiter's live state. Note that the
values in it are the ones in force now — the rate limiter lowers its own
drainRate while it throttles adaptively — not necessarily the ones you last
set.Clearing a value takes an explicit value, since an omitted key now means
"leave it alone": pass hardErrorCodes: [] to empty a list, or the field's
default to restore it. Spreading ...ParamsFactory.getDefault() resets
everything the factory names — but it carries no hardErrorCodes or
softErrorCodes key, so those two survive it. Name them yourself when you mean
to clear them.
Until #479 this method replaced the whole configuration, so a partial
update silently reset every parameter it did not mention — including the nested
blocks, which then reached their limiters as undefined. Examples in this
documentation spread ...ParamsFactory.getDefault() first for that reason;
doing so is still a good way to state a complete policy in one place.
Default Configurations
Default parameters for regular tariffs (standard)
{
rateLimit: {
burstLimit: 50, // 50 simultaneous requests
drainRate: 2, // 2 requests per second
adaptiveEnabled: true
},
operatingLimit: {
windowMs: 600000, // 10 minutes
limitMs: 480000, // 480 seconds (8 minutes)
heavyPercent: 80 // Notification at 80% usage
},
adaptiveConfig: {
enabled: true,
thresholdPercent: 80, // Activation at 80% of limit
coefficient: 0.01, // 1% of remaining blocking time
maxDelay: 7000 // Maximum 7 seconds delay
},
maxRetries: 3,
retryDelay: 1000,
retryOnNetworkError: true
}
Parameters for the Enterprise plan
{
...standard,
rateLimit: {
burstLimit: 250, // 250 simultaneous requests
drainRate: 5, // 5 requests per second
adaptiveEnabled: true
}
}
Parameters for bulk data processing
{
...standard,
rateLimit: {
burstLimit: 30,
drainRate: 1,
adaptiveEnabled: true
},
operatingLimit: {
windowMs: 600_000,
limitMs: 480_000,
heavyPercent: 50 // Higher threshold for notifications
},
adaptiveConfig: {
enabled: true,
thresholdPercent: 50, // More threshold
coefficient: 0.015, // More pause
maxDelay: 10_000 // Max 10 seconds
},
maxRetries: 5 // More attempts
}
Real-time parameters
{
...standard,
adaptiveConfig: {
enabled: false, // Off
thresholdPercent: 100,
coefficient: 0.001,
maxDelay: 480_000
},
maxRetries: 1
}
Monitoring and Statistics
Getting Statistics
import { ApiVersion } from '@bitrix24/b24jssdk'
// const $b24 = ...
const statsV2 = $b24.getHttpClient(ApiVersion.v2).getStats()
const statsV3 = $b24.getHttpClient(ApiVersion.v3).getStats()
Statistics structure:
{
// General statistics
retries: number, // Number of retry attempts
consecutiveErrors: number, // Consecutive errors
limitHits: number, // Limit hits
// Rate Limiter
tokens: number, // Current number of tokens
burstLimit: number, // Current burst limit
drainRate: number, // Current drain rate
// Adaptive Delayer
adaptiveDelays: number, // Number of applied delays
totalAdaptiveDelay: number, // Total delay time
adaptiveDelayAvg: number, // Average delay
// Operating Limiter
heavyRequestCount: number, // Number of heavy requests
operatingStats: { // Method statistics (in seconds)
[method: string]: number
},
// Errors by method
errorCounts: {
[method: string]: number
}
}
Resetting Statistics
// Complete reset of all limiter statistics
import { ApiVersion } from '@bitrix24/b24jssdk'
// const $b24 = ...
await $b24.getHttpClient(ApiVersion.v2).reset()
await $b24.getHttpClient(ApiVersion.v3).reset()
Long-Running Requests & Non-idempotent Calls
The SDK ships with a 30-second axios timeout and retries failed requests up to
maxRetries times. Both defaults are wrong for long-running,
non-idempotent REST methods — the canonical example is
crm.documentgenerator.document.add, which can take 10‒60 seconds to render a
template and is the original report behind issue #24.
Why it goes wrong by default
When the server takes longer than the axios timeout, this sequence plays out:
- Client opens the request, the server begins processing.
- Axios fires its timeout, the SDK sees
REQUEST_TIMEOUT(code: ECONNABORTED). REQUEST_TIMEOUTis treated as a transient error → SDK retries.- The server, unaware that the client gave up, finishes the first request and persists a document. Then it accepts the retry and persists another.
- After
maxRetriesattempts the SDK throws the underlying error (its real code) and the caller is left with 2-3 duplicates in CRM.
The same risk applies to every non-idempotent method: crm.deal.add,
crm.contact.add, disk.folder.uploadfile, tasks.task.add, any custom REST
endpoint that creates state.
Recommended configuration
Use this pattern for any call that creates an entity, regardless of expected duration:
import { ApiVersion, ParamsFactory } from '@bitrix24/b24jssdk'
// const $b24 = ...
// 1. Raise the timeout so the client actually waits for a slow operation.
const clientAxios = $b24.getHttpClient(ApiVersion.v2).ajaxClient
clientAxios.defaults.timeout = 120_000 // default is 30_000
// 2. Disable retries on transport errors so a client-side timeout never
// creates duplicate entities on the server.
await $b24.setRestrictionManagerParams({
...ParamsFactory.getDefault(),
retryOnNetworkError: false
})
// Now safe for non-idempotent calls.
const result = await $b24.actions.v2.call.make({
method: 'crm.documentgenerator.document.add',
params: { templateId: 42, entityTypeId: 2, entityId: 6014, values: {} }
})
retryOnNetworkError: false still produces duplicates
on a flaky network (the server replied, but the response never arrived). The
flag without the timeout fails too eagerly on requests that would have
completed in 35-40 seconds.Targeted use — per-call override
If you only need the strict behaviour for a specific code path, build a fresh
B24Hook for that call instead of mutating the global one:
import { B24Hook, ParamsFactory } from '@bitrix24/b24jssdk'
import { ApiVersion } from '@bitrix24/b24jssdk'
const $b24Strict = B24Hook.fromWebhookUrl(hookUrl, {
restrictionParams: {
...ParamsFactory.getDefault(),
retryOnNetworkError: false
}
})
$b24Strict.getHttpClient(ApiVersion.v2).ajaxClient.defaults.timeout = 120_000
await $b24Strict.actions.v2.call.make({ method: 'crm.deal.add', params: {/*…*/} })
Last resort — disable all retries
If you cannot afford any retry under any circumstances (e.g. a billing
operation), set maxRetries: 1:
import { ParamsFactory } from '@bitrix24/b24jssdk'
await $b24.setRestrictionManagerParams({
...ParamsFactory.getDefault(),
maxRetries: 1
})
This affects every error class, not just transport errors, so use sparingly.
Customizing Error Classification
The SDK groups REST error codes into three categories:
- Hard errors — thrown immediately as
AjaxError. No retry. Used for fatal conditions (authorization failures, invalid arguments, deleted portals). - Soft errors — returned inside
AjaxResultas an error payload, not thrown. Used for codes that callers typically inspect as part of normal control flow (e.g.ENTITY_NOT_FOUND, v3 validation errors). - Everything else — retryability is decided by HTTP status. Client errors
(HTTP
4xx, except429and408) are never retried — they are deterministic, so the SDK fails fast on the first attempt regardless of whether the error code is enumerated. Transient conditions (5xx,429,408, network errors) are retried up tomaxRetriestimes with backoff and jitter.
The built-in lists cover Bitrix24's standard REST surface. If your application
uses custom REST endpoints (e.g. from a local app or a placement handler) that
return their own error codes with a non-4xx status, those codes will be
retried by default — which is wrong for non-idempotent business errors.
Extend the classification via hardErrorCodes and softErrorCodes:
import { ParamsFactory } from '@bitrix24/b24jssdk'
// const $b24 = ...
await $b24.setRestrictionManagerParams({
...ParamsFactory.getDefault(),
// These will throw immediately instead of being retried:
hardErrorCodes: [
'DOCUMENT_GENERATOR_ALREADY_IN_QUEUE', // business code: don't retry
'MY_APP_INVALID_PAYLOAD' // custom REST: caller fix needed
],
// These will be returned in AjaxResult instead of thrown:
softErrorCodes: [
'MY_APP_VALIDATION_FAILED' // expected via normal flow
]
})
Classifying restApi:v3 errors by category
Extending the lists only helps for codes you already know about, and on
restApi:v3 you cannot know them all: a code is derived from the exception
class that raised it, so the set grows with every portal module. One
on-premise build was measured to ship at least 39 distinct v3 codes against
the nine in the built-in soft list — which makes the classification
per-module-shipping-date rather than per-error-kind. INVALIDSELECTEXCEPTION
is soft while INVALIDPAGINATIONEXCEPTION, the same caller mistake in the same
request at the same HTTP 400, throws.
The SDK decides from the response instead, and there is nothing to configure.
An error that arrived in the v3 error envelope carrying an HTTP 4xx other
than 401, 408 or 429 is soft, whatever its code. Pinned codes — the
built-in lists and your own — still outrank the rule, 5xx is untouched, and so
is restApi:v2, whose flat error body is not a v3 envelope.
2.x line this was opt-in, behind a
classifyV3ErrorsByCategory parameter that defaulted to false; in 3.0.0 the
rule is the behaviour and the parameter is gone. A v3 4xx that threw under 2.x
now resolves, so a try / catch written against the old behaviour stops firing
and control falls through into the success path. Move that handling to
if (!response.isSuccess). Full detail in
Error Codes,
migration steps in the
3.0.0 guide.INTERNAL_SERVER_ERROR, v3 validation codes, etc.) stay in place. This
prevents accidentally disabling critical safeguards like expired_token
detection.The built-in lists are exposed as static fields for reference:
import { RestrictionManager } from '@bitrix24/b24jssdk'
console.log(RestrictionManager.BUILT_IN_HARD_ERROR_CODES)
console.log(RestrictionManager.BUILT_IN_SOFT_ERROR_CODES)
One built-in hard code is raised by the SDK rather than by the portal, and it is
the exception to the retry story above: JSSDK_HTTP_REDIRECT_BLOCKED arrives
with status: 0, so retryOnNetworkError does not govern it — a
refused redirect is deterministic, and every retry would re-send a request
carrying an access token to a host that wants to redirect it. Being built-in and
hard, it also outranks softErrorCodes: naming it there does not turn it into a
returned error. See
Error Codes.
Instance-global scope of setRestrictionManagerParams
setRestrictionManagerParams() mutates the entire $b24 instance — it
reconfigures the single RestrictionManager shared by every request that goes
through that instance. It is not a per-call override.
This is a footgun on the server when one $b24 instance is shared across
concurrent requests (the common pattern: a module-level singleton reused by many
HTTP handlers). Changing params for one request changes them for all in-flight
requests on that instance.
import { ParamsFactory } from '@bitrix24/b24jssdk'
// ⚠️ ANTI-PATTERN: shared $b24, concurrent handlers
// Request A — a non-idempotent create that wants retries OFF:
await $b24.setRestrictionManagerParams({
...ParamsFactory.getDefault(),
retryOnNetworkError: false
})
await $b24.actions.v2.call.make({
method: 'crm.deal.add',
params: { fields: { TITLE: 'Deal A' } }
})
// Request B — running concurrently on the SAME $b24 — now ALSO sees
// retryOnNetworkError: false, even though it never asked for it. And if
// Request B calls setRestrictionManagerParams first, Request A's create
// silently runs with B's params. Last writer wins, globally.
Because the mutation is global and there is no per-request isolation, two
concurrent createDeal flows can clobber each other's rate-limit / retry
configuration.
Safe patterns
- Dedicated
$b24instance for non-idempotent / non-default flows. Build a separate instance (or passrestrictionParamsat construction) for the code path that needs different behaviour, so it never shares state with the default pool:import { B24Hook, ParamsFactory, ApiVersion } from '@bitrix24/b24jssdk' const $b24Strict = B24Hook.fromWebhookUrl(hookUrl, { restrictionParams: { ...ParamsFactory.getDefault(), retryOnNetworkError: false } }) $b24Strict.getHttpClient(ApiVersion.v2).ajaxClient.defaults.timeout = 120_000 await $b24Strict.actions.v2.call.make({ method: 'crm.deal.add', params: { fields: { TITLE: 'Deal A' } } }) - Set restriction params once at startup, not per-request. Treat them as instance configuration, not a per-call knob.
Idempotency-Key(restApi:v3only). PassidempotencyKeyon the call — a string naming one business operation, derived from your own identifiers (deal-${orderId}-create) so that a retry in another process produces the same key. Acrypto.randomUUID()written at the call site does not: the restarted process mints a new one and writes a duplicate. The portal stores the successful response against it for 24 hours and replays it on a repeat with the same key and body, so the duplicate is never created in the first place;AjaxResult.isIdempotentReplay()tells the replay from a fresh write. The response is stored only on success, so this complementsretryOnNetworkError: falserather than replacing it. See Call REST API v3 → Idempotency-Key.- Application-layer idempotency. On
restApi:v2, where the portal ignores the header, add your own idempotency guard (a dedup key / unique business field checked before insert) so a duplicate call is a no-op even if a retry slips through.
Usage Recommendations
Configuration for different scenarios:
import { ParamsFactory } from '@bitrix24/b24jssdk'
// const $b24 = ...
// Default parameters
$b24.setRestrictionManagerParams( ParamsFactory.getDefault() )
// Batch processing
$b24.setRestrictionManagerParams( ParamsFactory.getBatchProcessing() )
// Real-time
$b24.setRestrictionManagerParams( ParamsFactory.getRealtime() )
// By tariff plan
$b24.setRestrictionManagerParams( ParamsFactory.fromTariffPlan('enterprise') )
// Dynamic configuration change
$b24.setRestrictionManagerParams({
...ParamsFactory.getDefault(),
rateLimit: {
burstLimit: 30, // Temporary reduction in case of problems
drainRate: 1,
adaptiveEnabled: true
}
})