v3.0.0

Restrictions System

The restrictions system provides a comprehensive mechanism for managing request frequency, operation execution time, and adaptive delays.

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

  1. Token-based system: Each request consumes one token
  2. Automatic replenishment: Tokens are replenished at the rate of drainRate
  3. 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

  1. Sliding window: Tracks execution time over the last 10 minutes
  2. Method-specific tracking: Statistics are kept separately for each method
  3. Safety buffer: Calculations use limitMs - 5000 (5 second buffer)
  4. Blocking: When the limit is reached, blocks execution until statistics are reset

OperatingLimiter — Features

  • A batch is charged to the method batch, which is how the portal bills it — not spread over the methods inside it
  • Automatic cleanup of outdated data (> windowMs + 10 seconds)
  • Logging of heavy requests when exceeding heavyPercent
A self-hosted portal does not rate-limit REST at all by default, so this limiter has nothing to do there. The operating / operating_reset_at counters arrive only while the portal's own LoadLimiter is active, which on-premise reads the rest module option load_limiter_active — default N, and nothing in the product ever writes it. The same switch gates enforcement: LoadLimiter::is() returns false immediately when the limiter is inactive, so the portal is not refusing calls either. A quiet OperatingLimiter is therefore matching the portal rather than missing something. RateLimiter and AdaptiveDelayer are unaffected and still apply.

That also means a box has no protection against a runaway integration saturating REST. If you want it, turn the limiter on deliberately — but read what it costs first: you are switching on enforcement, not telemetry.

What the counters mean once they arrive

Measured on main 26.700.0 / rest 26.500.0. A self-hosted portal is in one of three states, and the middle one is the trap:

Portal stateoperatingoperating_reset_atUsable as a budget
load_limiter_active unset (default N)absentabsentNo — and nothing is being limited either
load_limiter_active = Y, no connection configuredpresent, never accumulatesabsentNo — and it does not look broken
load_limiter_active = Y + a working connectionaccumulates across requestsbucket expiryYes

The middle state is the dangerous one, and not for the reason you would guess. operating there is not a constant zero: it is the sum accumulated within the current request, so a quick call reads 0 only because it finished under the portal's minimalFixTime of 0.1 s, while a 50-command batch reads a plausible 0.16. Measured across three identical batch runs it read 0.16, then 0, then 0.157 — a number that moves, looks real, and never grows. A client that throttles on it will never throttle.

What makes this reachable: flipping one option is all it takes, and the connection does not appear on its own. A broken connection lands here too — getConnectionResource() nulls the resource when isConnected() is not true, so a typo in the connection name or a stopped Redis silently returns the portal to this state with no signal in the REST response.

This is why the SDK never synthesises operating: 0 when the counters are missing. A fabricated zero would put every portal into the middle state — confidently wrong instead of honestly blind.

Properties of the portal's budget that the numbers do not show

Measured on the same build, and each of them contradicts a reasonable guess:

  • The budget is per method, not per portal or per credential. The portal keys it on sha1(auth type | credential | method). Measured: with the batch budget standing at 1.228, tasks.task.list read 0 at the same moment, on both API versions. A portal-wide "REST is at 60% of budget" figure cannot be built from these numbers, and a batch is charged to batch rather than to the methods inside it.
  • A batch spends half the time it takes. The portal applies a BATCH_TIME_WEIGHT of 0.5. Confirmed twice, in-request and through the connection. The SDK does not need to correct for this — the number it reads has the weight already applied.
  • The window is 600 seconds, not 420. It is ten 60-second buckets. The 420 is the threshold within that window, overridable on a box through load_limiter_second_limit; on the cloud the same override comes from a feature variable.
  • A call shorter than 0.1 s is not counted at all.
  • operating_reset_at is not a reliable deadline. The portal adds it only when its reset time comes back truthy, which is why it is missing entirely in the middle state of the table above — measured. When it is present it is the stored buckets' expiry, a fixed point that stays put across requests rather than sliding forward with each one. Reading the module's sources suggests a third case, in which nothing is stored and the value falls through to start of this request + 600; that case was not observed, so treat a present value as a hint rather than a promise.
  • Retry-After is never sent. A grep over the whole rest module finds no occurrence, and no response in fifteen minutes of sustained load carried one. There is nothing for the SDK to honour and it does not look for one.

Every cloud portal we have measured sends the counters, on restApi:v2 and restApi:v3 alike.

What sustained load actually does

Measured on a cloud portal, because the enforcement path is the same class with the same connector there as on a configured box — the cloud simply always has it switched on.

Two runs of 50-command batches, six then fourteen concurrent streams, fifteen minutes and roughly 11 000 requests (about half a million sub-commands). Not one refusal: no 429, no 503, no OVERLOAD_LIMIT, and no Retry-After or X-RateLimit-* header on any response, which confirms by measurement what a grep over the module's sources already suggested.

What the counter did instead is the interesting part:

Loadoperating behaviour
single callsfreezes after the first — each later call finishes under the 0.1 s floor and contributes nothing
6 concurrent batch streamsclimbs steadily, then plateaus at ~266 s and stays there
14 concurrent batch streamsoscillates between ~270 and ~383, never higher

The plateau is the rolling window reaching steady state: past that point it discards as fast as the load adds. Both plateaus sit below the 420-second threshold, and doubling the concurrency barely moved the ceiling. So on a portal of this size the operating limit is not merely hard to reach — at a light-but-frequent workload it is unreachable by construction, and OperatingLimiter will never have cause to delay anything.

That is worth knowing before tuning operatingLimit: if your calls are short, the budget you are modelling is one the portal will not enforce. The limits you will actually meet are the request-rate ones.

The refusal's shape on the wire is therefore still unobserved — not for want of trying. What the portal throws is read from its sources: restApi:v2 raises OPERATION_TIME_LIMIT at HTTP 429, restApi:v3 a rate-limit exception at the same status. That is also why the SDK's classification of a v3 429 is left as it is rather than changed on an assumption — see Three layers produce a throttling error.
Do not use time.duration or time.processing for timing on this build.CRestServer declares timeStart / timeProcessStart / timeProcessFinish as int and assigns microtime(true) to them, so the fraction is silently truncated. duration is inflated by up to a full second (measured: 0.29, 0.45, 0.42 s on three batch envelopes) and processing is quantised to whole seconds, reading 0 for almost every call. The X-Bitrix-Rest-Time header is built from the same two values — measured, it reads 0.0000000000 on an ordinary successful call. The SDK measures its own request durations with a monotonic clock and does not read either field.

Enabling the operating limiter on a self-hosted portal

The steps below were run on a real portal, in this order. They are for the box owner, not for application code — nothing here changes how the SDK behaves.

You are switching on enforcement, not telemetry. Every webhook and OAuth application on the portal becomes subject to the operating-time budget, per method. An integration that has quietly been living above it will start failing the day this is enabled. Roll it out in a quiet window and watch for 429s for a day before trusting it.

1. A Redis for it, bound to loopback. Memcached cannot stand in: the limiter calls exists, setEx, ttl, del, incrByFloat, expireAt and a raw MGET, which is the phpredis surface. A box whose cache engine is memcached still needs a Redis declared separately.

bind 127.0.0.1 -::1
protected-mode yes
port 6379
save ""
appendonly no
maxmemory 128mb
maxmemory-policy allkeys-lru

Persistence is off deliberately: the counters are a sliding ten-minute window, every key carries a TTL, and losing them on restart only makes the limiter more permissive. allkeys-lru is chosen for the same reason — under memory pressure, drop counters rather than fail writes. There is no password because there is no listener outside the loopback; do not open the port on the firewall.

2. Declare the connection in bitrix/.settings.php under connections → value. Copy the file first: a syntax error there takes the whole portal down.

'cache.redis' => [
    'className' => \Bitrix\Main\Data\RedisConnection::class,
    'host' => '127.0.0.1',
    'port' => 6379,
    'persistent' => false,
],

3. Verify the pool before enabling anything. Call getResource() first and isConnected() after — the connection is lazy, and checking in the other order reports false on a perfectly good connection.

$c = \Bitrix\Main\Application::getInstance()->getConnectionPool()->getConnection('cache.redis');
$r = $c->getResource();
echo get_class($r), ' ', var_export($c->isConnected(), true), ' ', var_export($r->ping(), true);

This step is not optional. A wrong name or a stopped Redis leaves you in the middle state of the table above, and nothing in the REST response says so.

4. Enable.

\Bitrix\Main\Loader::includeModule('rest');
\Bitrix\Main\Config\Option::set('rest', 'load_limiter_active', 'Y');
\Bitrix\Main\Config\Option::set('rest', 'load_limiter_connection_name', 'cache.redis');

5. Confirm accumulation rather than assuming it. Call one method repeatedly and watch time.operating grow between responses. A value that moves but never grows means step 3 failed.

To roll back, set both options back to 'N' and ''.

Operating notes

  • The only tuning knob is load_limiter_second_limit, which replaces the 420-second threshold. BAN_DURATION is a private constant at 300 seconds and cannot be configured. If the default proves too tight for a legitimate integration, raising this option is the supported move; disabling the limiter is not.
  • Bans do not reach the portal's exception log. The v3 rate-limit exception is marked to skip the log. Watch the web server's access log for 429 responses under /rest/ instead. Switch the limiter on and the portal will start refusing requests without leaving a trace where an administrator looks first.
  • A Redis outage fails open. Nothing accumulates, the threshold is never reached, and REST keeps serving. That is the right failure direction — but a silently dead Redis looks exactly like a healthy, unloaded portal.

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

  • A batch is delayed on the batch budget, the one the portal bills it against — not on the busiest method inside it
  • 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

  1. Operating Limit Check: Checks operation time limit
  2. Adaptive Delay: Applies adaptive delay if necessary
  3. Rate Limit: Checks and applies rate limiting (loop for parallel requests)

Error Handling

// Determining error type, in the order they are tested
#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)

Three layers produce a throttling error, not two

The two matchers above are a two-way split, but the portal has three places that refuse a call for load:

LayerWhere it is raisedShape on the wireHow the SDK classifies it
OVERLOAD_LIMITthe authorization layer, before the URL version is parsedflat pre-v3 error, on any URL — restApi:v2 and restApi:v3 alikein the built-in hard-code list, but the status decides: at the 503 the error reference documents for it, the rate-limit branch claims it first and retries
QUERY_LIMIT_EXCEEDEDa cloud-side layer (no occurrences in the self-hosted sources)flat pre-v3 errorrate limit — RateLimiter.handleExceeded()
RATELIMITEXCEPTIONthe v3 layer itselfHTTP 429, v3 envelope, code BITRIX_REST_V3_EXCEPTION_RATELIMITEXCEPTIONoperating limit, by HTTP status alone

Two things are worth stating plainly.

A v3 rate-limit error is currently handled by the operating-limit branch, because #isOperatingLimitError() matches status === 429 on its own. The consequences are visible rather than dangerous — the log line names OPERATION_TIME_LIMIT, the wait comes from operating-time statistics (on a self-hosted portal there are none, so it is a flat 10-second floor), and RateLimiter never learns it was throttled, so it does not shrink its own budget.

And a code being in the hard-code list does not settle what happens to it: the two status matchers are tested before that check is reached, so OVERLOAD_LIMIT arriving at 503 is retried as a rate limit, and the hard-code listing only takes effect for a shape that carries no status.

The first of these is left that way deliberately. Reclassifying it means knowing which layer answers first on a v3 URL under load, and whether the portal sends a Retry-After worth honouring instead of our backoff — the SDK reads no such header today. Both questions need a measurement on a disposable portal, since a self-hosted ban lasts a fixed 300 seconds. Guessing would trade a wrong label for a wrong retry schedule. See #459.

Retry Strategy

  1. For limit errors: Exponential delay considering the attempt number
  2. For client errors (HTTP 4xx, except 429 / 408): No retries — a 4xx response 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 as AjaxError, or returned inside AjaxResult for soft codes). 429 is retried as a rate/operating limit; 408 (request timeout) stays transient.
  3. For other errors: Basic backoff with jitter (±10%)
  4. 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 })
The merge is shallow: the nested blocks are replaced whole.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:

  1. Client opens the request, the server begins processing.
  2. Axios fires its timeout, the SDK sees REQUEST_TIMEOUT (code: ECONNABORTED).
  3. REQUEST_TIMEOUT is treated as a transient error → SDK retries.
  4. The server, unaware that the client gave up, finishes the first request and persists a document. Then it accepts the retry and persists another.
  5. After maxRetries attempts 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.

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: {} }
})
Either step alone is not enough. A long timeout without 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:

  1. Hard errors — thrown immediately as AjaxError. No retry. Used for fatal conditions (authorization failures, invalid arguments, deleted portals).
  2. Soft errors — returned inside AjaxResult as 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).
  3. Everything else — retryability is decided by HTTP status. Client errors (HTTP 4xx, except 429 and 408) 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 to maxRetries times 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.

Changed in 3.0.0. Through the 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.
Merge, not replace. User-provided codes are appended to the built-in lists. You can only add codes — the built-ins (authorization codes, 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

  1. Dedicated $b24 instance for non-idempotent / non-default flows. Build a separate instance (or pass restrictionParams at 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' } }
    })
    
  2. Set restriction params once at startup, not per-request. Treat them as instance configuration, not a per-call knob.
  3. Idempotency-Key (restApi:v3 only). Pass idempotencyKey on 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. A crypto.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 complements retryOnNetworkError: false rather than replacing it. See Call REST API v3 → Idempotency-Key.
  4. 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
  }
})