A failed call reaches you in one of two ways, and the difference is delivery, not severity:
- Hard — the promise rejects and the SDK throws
AjaxError(or a subclass). Handle it intry / catch. - Soft — the promise resolves; the error is packed into the returned
AjaxResultandresponse.isSuccess === false. Handle it with anif.
Two things this split does not decide. It does not affect retries — any 4xx other than 408 and 429 stops retrying regardless, and a pinned code of either kind stops retrying too. And an unlisted code behaves like a hard one: anything not classified as soft is thrown.
Everything else (network errors, 5xx, rate-limit codes) is subject to the retry strategy described in Limiters.
The tables are not a catalogue
The two tables below list codes whose classification is pinned. They are not the set of codes a portal can send.
A restApi:v3 code is derived from the exception class that raised it, so the set a build can produce grows with every portal module. One on-premise build was measured to ship at least 39 distinct v3 codes; the pinned soft list holds nine. The rest are thrown today — including INVALIDPAGINATIONEXCEPTION, UNKNOWNFILTEROPERATOREXCEPTION and INVALIDORDEREXCEPTION, which are the same caller mistake, in the same request, at the same HTTP 400, as the INVALIDSELECTEXCEPTION and INVALIDFILTEREXCEPTION that are pinned soft.
Do not match on the BITRIX_REST_V3_EXCEPTION_ prefix, and do not derive a code by stripping it. Modules ship their own v3 exceptions with short unprefixed codes — NOTE_SEARCH_QUERY_TOO_SHORT, NOTE_FILE_TOO_LARGE, CRM_EMAIL_INVALID_REQUEST and others. Match on the whole string or not at all. The message is localised and must never be matched on.
Classification by category (restApi:v3)
Rather than growing the list, the SDK decides from the response itself: an error that arrived in the v3 error envelope carrying an HTTP 4xx other than 401, 408 or 429 is soft, whatever its code. This needs no configuration — it is the behaviour.
// const $b24 = ...
const response = await $b24.actions.v3.call.make({
method: 'main.eventlog.list',
params: { pagination: { limit: 0 } }
})
// Reached for any 4xx the portal reports, not only the nine pinned codes.
if (!response.isSuccess) {
console.log(response.getErrorMessages().join('; '))
}
The order of decision is fixed: a code in the hard list throws; a code in the soft list is soft; then the category rule; then, as today, it throws. So a pinned classification — including your own hardErrorCodes / softErrorCodes — always outranks the category.
What the rule does not touch:
- 5xx — not caller-addressable, so retry-then-throw stays right.
- 401 — the auth-refresh path owns it.
- 408 and 429 — the two retryable 4xx; an error still being retried has not been classified yet.
restApi:v2— the flat error body is not a v3 envelope. The decision is made from the body that actually arrived, never from the client's version, because a gateway in front of the v3 controller can answer in the flat shape.
403 is soft, deliberately. A permission refusal is something the caller can act on, and …ACCESSDENIEDEXCEPTION is already pinned soft — excluding 403 wholesale would leave one 403 returned and its neighbour thrown.
One 403 is pinned hard against that rule: BITRIX_REST_V3_EXCEPTION_INSUFFICIENTSCOPEEXCEPTION. It means the application's OAuth grant is missing a scope — a configuration fault rather than a per-record permission check — and the v2 spelling of the same condition, insufficient_scope, has always been thrown. The two are different strings, so without pinning the v3 form the same failure would have been delivered one way on v2 and another on v3.
2.x line this rule was opt-in, behind a classifyV3ErrorsByCategory parameter; in 3.0.0 it is the behaviour and the parameter is gone. That changes how an error is delivered: a v3 4xx that threw under 2.x now resolves, so a try / catch around the call stops firing and control falls through into the success path. Move that handling to if (!response.isSuccess) — see the migration guide.Pinned hard codes (thrown)
These codes propagate through the promise rejection path and are never softened by the category rule. Wrap your calls in try / catch if you need to handle them centrally.
Pinned soft codes (returned in AjaxResult)
These codes do not throw — they appear in result.getErrors() (and getErrorMessages()). Check isSuccess first. With the category rule on, many more v3 codes join them without being listed here.
Retry-Triggering Codes
These are not in the catalog above — they are detected by the RestrictionManager and trigger automatic retries with backoff:
- HTTP 503 /
QUERY_LIMIT_EXCEEDED— rate limit. - HTTP 429 /
OPERATION_TIME_LIMIT— operating limit. - Any other transient code → exponential backoff with jitter.
After maxRetries (default 3) the SDK gives up and surfaces the underlying error with its real code (e.g. QUERY_LIMIT_EXCEEDED) — thrown for hard codes, or returned in the AjaxResult for soft codes.
NETWORK_ERROR and REQUEST_TIMEOUT are not in the hard list, which means non-idempotent calls (e.g. crm.documentgenerator.document.add) can be retried after a server-side timeout and create duplicates. If you hit this, consider wrapping the call in a manual idempotency key on your side.Working with AjaxError
Both buckets surface as AjaxError (a subclass of SdkError). Useful properties:
class AjaxError extends SdkError {
readonly code: string
readonly message: string
readonly status: number
readonly requestInfo?: { method?: string, params?: TypeCallParams, requestId?: string, url?: string }
}
Pattern for unified handling:
import { AjaxError } from '@bitrix24/b24jssdk'
async function callWithUnifiedErrorHandling(method: string, params: Record<string, unknown>, requestId: string) {
try {
const response = await $b24.actions.v3.call.make({ method, params, requestId })
if (!response.isSuccess) {
for (const error of response.getErrors()) {
if (error instanceof AjaxError) {
// Soft error
console.warn(error.code, error.requestInfo?.method)
}
}
return null
}
return response.getData()
} catch (error) {
if (error instanceof AjaxError) {
// Hard error
console.error('SDK threw', error.code, error.status)
}
throw error
}
}