@andrey4emk/npm-app-back-b24 3.8.2 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,478 @@
1
+ import { AjaxError, RefreshTokenError, SdkError } from "@bitrix24/b24jssdk";
2
+ import { logs } from "../../logs/logs.ts";
3
+ import { isNetworkError, isPreConnectionError } from "../../utils/fetchRetry.ts";
4
+
5
+ // ==================== Константы ====================
6
+
7
+ /** Количество попыток при сетевых ошибках */
8
+ export const RETRY_COUNT = 5;
9
+ /** Задержка между попытками (мс) */
10
+ export const RETRY_DELAY_MS = 500;
11
+
12
+ /**
13
+ * Коды транспортного сбоя. Один набор и для SdkError (AjaxError, RefreshTokenError),
14
+ * и для вложенного AxiosError — списки совпадали, держать их раздельно смысла нет.
15
+ */
16
+ export const NETWORK_ERROR_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT", "ERR_NETWORK", "ECONNABORTED"]);
17
+
18
+ /**
19
+ * REST-методы B24, повтор которых создаёт дубликат сущности или запускает
20
+ * повторное действие: add, create, start, send, uploadfile, import, register.
21
+ *
22
+ * Транспортная ошибка не говорит, дошёл ли запрос до портала: обрыв приходит
23
+ * и когда соединение не состоялось, и когда оно оборвалось после того, как портал уже
24
+ * принял и выполнил запрос. Во втором случае retry создаст вторую задачу, второй файл,
25
+ * второй экземпляр бизнес-процесса. Идемпотентные записи (update, delete, set) повторять
26
+ * безопасно: повторное применение даёт то же состояние. При доказанном pre-connection
27
+ * (isPreConnectionError) ограничение снимается: соединение с целевым хостом не состоялось,
28
+ * портал запроса не видел, и дубликата повтор дать не может.
29
+ *
30
+ * По той же причине у SDK отключены собственные повторы транспортных сбоев
31
+ * (SDK_RESTRICTION_PARAMS) — он этой разницы не знает вовсе.
32
+ *
33
+ * Ключевое слово ловится **в любой позиции** имени, а не только в конце. Иначе мимо
34
+ * гейта проходил `imconnector.send.messages` (дефект T-48) — метод создаёт в открытой
35
+ * линии сообщение внешней системы. Дедупликация по `message.id` в документации портала
36
+ * не описана, поэтому повтор считаем задваивающим переписку у оператора — fail-closed.
37
+ * После ключевого слова обязателен разделитель (`.`, `?` или конец строки), поэтому
38
+ * `crm.deal.addcustom`, `landing.landing.addbytemplate` и `tasks.task.result.addresult`
39
+ * по-прежнему повторяются. Отдельная группа `(\.json)?` больше не нужна: хвост `.json`
40
+ * покрывается общей веткой `\.`.
41
+ *
42
+ * Побочный улов расширения — `imconnector.send.status.delivery`: он лишь помечает уже
43
+ * отправленное сообщение доставленным и, скорее всего, идемпотентен. Повторяться он
44
+ * перестанет, и это осознанная плата за правило без списка исключений: потеря отметки
45
+ * о доставке косметическая, переписку она не ломает, а ручной список молча устаревал бы
46
+ * при появлении нового метода портала — ровно тот дефект, которым T-48 и был.
47
+ */
48
+ export const NON_IDEMPOTENT_METHOD_RE = /\.(add|create|start|send|uploadfile|import|register)(\.|\?|$)/i;
49
+
50
+ /**
51
+ * Методы, повтор которых теряет данные, хотя сущностей не создаёт.
52
+ * Совпадение точное, по имени целиком (после нормализации), а не по префиксу.
53
+ *
54
+ * `event.offline.get` резервирует пакет очереди офлайн-событий и прячет его от следующих
55
+ * запросов. Если ответ первой попытки потерян по дороге, `process_id` пакета не узнает
56
+ * никто: записи висят зарезервированными до автоудаления через 30 дней и повторно
57
+ * не выдаются.
58
+ *
59
+ * Ограничение действует независимо от параметра `clear`, и вот почему:
60
+ *
61
+ * 1. при `clear: 0` первый вызов резервирует пакет и прячет его от следующих запросов —
62
+ * потерянный ответ уносит `process_id` с собой;
63
+ * 2. при `clear: 1` портал отдаёт записи и тут же удаляет их — потерянный ответ уносит
64
+ * их безвозвратно, то есть повтор так же теряет пакет, только иначе;
65
+ * 3. разбор по параметрам означал бы чтение `args[1]` в гейте, который смотрит только
66
+ * на имя, — лишняя сложность ради ветки, где обе стороны одинаково плохи;
67
+ * 4. fail-closed дешевле: единственный потребитель опрашивает очередь по таймеру,
68
+ * следующий опрос всё равно будет.
69
+ *
70
+ * Чего правка **не** делает: она не спасает первый потерянный пакет — он уже
71
+ * зарезервирован. Она предотвращает вторую потерю, то есть повтор, который зарезервировал
72
+ * бы ещё один пакет поверх первого. Ограничение действует только на слой пакета:
73
+ * собственный цикл повторов SDK (429, 503, неизвестные 5xx) под него не попадает —
74
+ * при 503 лимитер считает ответ временным отказом раньше, чем смотрит на hardErrorCodes,
75
+ * и делает свои три запроса, см. T-44.
76
+ *
77
+ * При доказанном pre-connection (соединение не состоялось) ограничение снимается вместе
78
+ * с гейтом идемпотентности: портал запроса не видел, резервировать было нечего.
79
+ */
80
+ export const NON_RETRYABLE_METHODS = new Set(["event.offline.get"]);
81
+
82
+ /** Префикс кодов, которыми SDK помечает собственные отказы обмена refresh-токена */
83
+ const OAUTH_REFRESH_CODE_PREFIX = "JSSDK_OAUTH_TOKEN_REFRESH";
84
+
85
+ // ==================== Утилиты ====================
86
+
87
+ /** Задержка на указанное количество миллисекунд */
88
+ export const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
89
+
90
+ /**
91
+ * Приводит имя REST-метода к виду, пригодному для точного сравнения:
92
+ * без пробелов по краям, без регистра, без хвоста `?query` и без расширения `.json`.
93
+ *
94
+ * Нужна списку NON_RETRYABLE_METHODS: он сравнивает имя целиком, а до него имя доезжает
95
+ * в любой из форм — `event.offline.get`, `Event.Offline.Get`, `event.offline.get.json`,
96
+ * `event.offline.get?clear=0`.
97
+ */
98
+ function normalizeMethodName(raw: string): string {
99
+ // noUncheckedIndexedAccess: индексация массива даёт string | undefined
100
+ const head = raw.trim().toLowerCase().split("?")[0] ?? "";
101
+ return head.replace(/\.json$/, "");
102
+ }
103
+
104
+ /** Фраза для лога: повтор отменён, потому что вызов создаёт сущности */
105
+ const creatingReason = (what: string): string => `вызов создаёт сущности (${what})`;
106
+
107
+ /** Фраза для лога: повтор отменён, потому что вызов резервирует данные на портале */
108
+ const reservingReason = (what: string): string => `вызов резервирует данные на портале (${what})`;
109
+
110
+ // ==================== Retry-обёртка ====================
111
+
112
+ /**
113
+ * Проверяет, является ли ошибка из SDK сетевой.
114
+ *
115
+ * Форма транспортного сбоя в Node: до нас доезжает **сырой errno** — ENOTFOUND,
116
+ * ECONNREFUSED, ECONNRESET со status 0. Ветки NETWORK_ERROR и REQUEST_TIMEOUT в SDK
117
+ * рассчитаны на другое: первая подставляется только при axios-коде ERR_NETWORK
118
+ * (браузерный адаптер), вторая — при ECONNABORTED. Node-адаптер зовёт
119
+ * `AxiosError.from(err, null, ...)`, и код остаётся исходным errno. Держим оба набора:
120
+ * сырые коды ловит последняя строка через isNetworkError(), а именованные — набор
121
+ * NETWORK_ERROR_CODES.
122
+ *
123
+ * Проверку по status 0 оставляем: она ловит всё, что SDK отдаёт без внятного кода.
124
+ * Сюда же попадает провал обновления токена внутри callMethod: SDK перезаворачивает
125
+ * SdkError в AjaxError с кодом JSSDK_UNKNOWN_ERROR и status 0, а исходную ошибку кладёт
126
+ * в originalError. RefreshTokenError приходит как SdkError с кодом вроде ENOTFOUND;
127
+ * при status 500 такая ошибка признаётся сетевой не веткой status === 0, а последней
128
+ * строкой — код есть в списке utils/fetchRetry.ts.
129
+ *
130
+ * Сетевой провал обмена токена остаётся, а повторяться перестаёт: решение принимает
131
+ * runWithRetry через isTokenRefreshFailure(). Классификация «сетевая ли ошибка»
132
+ * и решение «повторять ли её» — разные вопросы, и смешивать их здесь нельзя: иначе
133
+ * ошибка ушла бы наружу первой веткой цикла, без единой строки в логе.
134
+ */
135
+ export function isB24NetworkError(error: unknown): boolean {
136
+ if (error instanceof AjaxError) {
137
+ // Практически недостижимо (SDK бросает реальную ошибку раньше),
138
+ // но если код всё же пришёл — SDK уже исчерпал свои попытки
139
+ if (error.code === "JSSDK_CALL_ALL_ATTEMPTS_EXHAUSTED") return false;
140
+ }
141
+
142
+ if (error instanceof SdkError) {
143
+ const { code, originalError, status } = error;
144
+ const originalCode = (originalError as { code?: string } | undefined)?.code;
145
+
146
+ if (NETWORK_ERROR_CODES.has(code)) return true;
147
+ if (originalError && isNetworkError(originalError)) return true;
148
+ if (originalCode && NETWORK_ERROR_CODES.has(originalCode)) return true;
149
+ if (status === 0) return true;
150
+
151
+ // 502 Bad Gateway: шлюз перед порталом не смог получить ответ от upstream —
152
+ // запрос почти наверняка не выполнялся, повтор осмыслен. Мы держим этот код
153
+ // в SDK_RESTRICTION_PARAMS.hardErrorCodes, чтобы SDK его не повторял, но
154
+ // повторить его должен наш слой: там создающие вызовы отсекает гейт.
155
+ //
156
+ // 504 сюда намеренно не входит, хотя приходит тем же кодом ERR_BAD_RESPONSE:
157
+ // это «портал взял запрос и считает прямо сейчас, шлюз устал ждать». Повтор
158
+ // создающего вызова дал бы дубликат, а читающего — ничего: за таймаут шлюза
159
+ // запрос не уложился один раз, не уложится и на второй, зато пять тяжёлых
160
+ // повторов добавят нагрузки порталу ровно тогда, когда ему и так плохо.
161
+ if (status === 502) return true;
162
+ }
163
+
164
+ return isNetworkError(error);
165
+ }
166
+
167
+ /**
168
+ * Провалился ли внутри вызова обмен refresh-токена.
169
+ *
170
+ * SDK перезаворачивает такую ошибку в `_convertUnknownErrorToAjaxError`
171
+ * (`core/http/abstract-http.mjs`): RefreshTokenError наследует SdkError, а не AjaxError
172
+ * и не AxiosError, поэтому попадает в ветку «неизвестная ошибка» и **кладётся целиком
173
+ * в originalError**. Снаружи всегда одно и то же — AjaxError JSSDK_UNKNOWN_ERROR
174
+ * со status 0, а исходная причина различима внутри:
175
+ *
176
+ * | Что случилось при обмене | originalError |
177
+ * |---|---|
178
+ * | oauth-хост не резолвится | RefreshTokenError ENOTFOUND, status 0 |
179
+ * | oauth-хост отверг соединение | RefreshTokenError ECONNREFUSED, status 0 |
180
+ * | 400 invalid_grant (мёртвый токен) | RefreshTokenError invalid_grant, status 400 |
181
+ * | 500 от oauth-хоста | RefreshTokenError ERR_BAD_RESPONSE, status 500 |
182
+ * | 200 с полем error в теле | SdkError JSSDK_OAUTH_TOKEN_REFRESH_FAILED, status 0 |
183
+ *
184
+ * Форма одна и та же независимо от того, откуда запустился обмен: после 401 от портала
185
+ * (`_makeRequestWithAuthRetry`) или заранее, из-за протухшего `expires` (`_ensureAuth`).
186
+ * Обе ветки зовут `_refreshAuth()` и бросают в один catch метода `call()`.
187
+ *
188
+ * Две ловушки, из-за которых разбор выглядит именно так:
189
+ *
190
+ * - RefreshTokenError **не переопределяет `name`** — у экземпляра `name === "SdkError"`,
191
+ * и проверка по имени не работает. Работают instanceof и constructor.name;
192
+ * - последняя строка таблицы даёт не RefreshTokenError, а голый SdkError с кодом
193
+ * из семейства JSSDK_OAUTH_TOKEN_REFRESH_* — её ловим отдельно.
194
+ *
195
+ * Запасная проверка по constructor.name нужна на случай двух копий SDK в node_modules
196
+ * у потребителя: instanceof тогда промахнётся. Промах дешёвый — поведение откатится
197
+ * к прежнему (пять попыток и шум в логе), данные не пострадают.
198
+ *
199
+ * @param error — пойманная ошибка
200
+ * @returns true, если внутри лежит отказ обмена refresh-токена
201
+ */
202
+ export function isTokenRefreshFailure(error: unknown): boolean {
203
+ if (!(error instanceof SdkError)) return false;
204
+
205
+ const { originalError } = error;
206
+ if (!originalError || typeof originalError !== "object") return false;
207
+
208
+ if (originalError instanceof RefreshTokenError) return true;
209
+ if (originalError.constructor?.name === "RefreshTokenError") return true;
210
+
211
+ return originalError instanceof SdkError && originalError.code.startsWith(OAUTH_REFRESH_CODE_PREFIX);
212
+ }
213
+
214
+ /**
215
+ * Код исходной ошибки обмена токена со статусом ответа oauth-хоста, если он известен:
216
+ * `invalid_grant/400`, `ERR_BAD_RESPONSE/503`, `ENOTFOUND`,
217
+ * `JSSDK_OAUTH_TOKEN_REFRESH_FAILED`.
218
+ *
219
+ * Нужен лог-строке: код внешней ошибки всегда JSSDK_UNKNOWN_ERROR и для разбора
220
+ * инцидента бесполезен.
221
+ *
222
+ * Статус дописывается потому, что без него один код накрывает разные инциденты:
223
+ * `ERR_BAD_RESPONSE` одинаков для 500 и 503, `ERR_BAD_REQUEST` — для 429 и прочих 4xx.
224
+ * По логу нельзя отличить «токен мёртв, нужна переавторизация» от «oauth-хост занят,
225
+ * пройдёт само», а это первый вопрос дежурного. Доступ законный: `status` у `SdkError` —
226
+ * публичный геттер. При status 0, отсутствующем или нечисловом хвоста нет: ноль SDK
227
+ * ставит транспортному сбою, где статуса ответа не существует.
228
+ */
229
+ export function refreshFailureCode(error: unknown): string {
230
+ const originalError = error instanceof SdkError ? error.originalError : undefined;
231
+ const code = (originalError as { code?: unknown } | undefined)?.code;
232
+
233
+ if (typeof code !== "string" || code.length === 0) return "код неизвестен";
234
+
235
+ const status = (originalError as { status?: unknown } | undefined)?.status;
236
+
237
+ return typeof status === "number" && Number.isFinite(status) && status > 0 ? `${code}/${status}` : code;
238
+ }
239
+
240
+ /**
241
+ * Достаёт имя REST-метода из одной команды batch.
242
+ * Возвращает null, если форма команды не распознана.
243
+ */
244
+ export function extractBatchCommandMethod(cmd: unknown): string | null {
245
+ // Кортеж ["crm.deal.add", { ... }] — основная форма в SDK 2.x
246
+ if (Array.isArray(cmd)) {
247
+ return typeof cmd[0] === "string" ? cmd[0] : null;
248
+ }
249
+
250
+ // Объект { method, params }
251
+ if (cmd && typeof cmd === "object") {
252
+ const method = (cmd as { method?: unknown }).method;
253
+ return typeof method === "string" ? method : null;
254
+ }
255
+
256
+ // Строка "crm.deal.add?ID=1" — в SDK 2.x эта форма уже не поддерживается
257
+ // (ParseRow бросает JSSDK_INTERACTION_BATCH_ROW_FAIL), разбираем на случай,
258
+ // если потребитель остался на ней со старой версии
259
+ if (typeof cmd === "string") {
260
+ return cmd;
261
+ }
262
+
263
+ return null;
264
+ }
265
+
266
+ /** К какому виду ограничения относится имя метода */
267
+ type MethodRestriction = "creating" | "reserving" | null;
268
+
269
+ /** Классифицирует одно имя REST-метода: создаёт, резервирует либо повторяется свободно */
270
+ function classifyMethod(name: string): MethodRestriction {
271
+ if (NON_RETRYABLE_METHODS.has(normalizeMethodName(name))) return "reserving";
272
+ if (NON_IDEMPOTENT_METHOD_RE.test(name)) return "creating";
273
+
274
+ return null;
275
+ }
276
+
277
+ /**
278
+ * Собирает итоговую фразу причины из двух списков имён.
279
+ *
280
+ * Порядок фиксирован — сначала создающие, потом резервирующие: лог-строка должна быть
281
+ * одинаковой при одинаковом составе вызова.
282
+ */
283
+ function joinReasons(creating: string[], reserving: string[]): string | null {
284
+ const phrases: string[] = [];
285
+
286
+ if (creating.length > 0) phrases.push(creatingReason(creating.join(", ")));
287
+ if (reserving.length > 0) phrases.push(reservingReason(reserving.join(", ")));
288
+
289
+ return phrases.length > 0 ? phrases.join("; ") : null;
290
+ }
291
+
292
+ /**
293
+ * Возвращает готовую фразу причины, по которой вызов запрещено повторять, либо null.
294
+ *
295
+ * Функция отдаёт именно фразу, а не имя метода: причин теперь две (вызов создаёт
296
+ * сущности либо резервирует данные на портале), и шаблон в лог-строке соврал бы про
297
+ * `event.offline.get` — он ничего не создаёт. runWithRetry дописывает вокруг только
298
+ * «повтор отменён, ...», поэтому строки для создающих вызовов остались прежними
299
+ * до последнего байта: по ним грепают в логах и на них настроены триггеры задач.
300
+ *
301
+ * Для callBatch действует правило fail-closed: если хоть одну команду разобрать
302
+ * не удалось, вызов считается создающим. Ошибиться в сторону лишней осторожности
303
+ * дешевле — потребитель получит ошибку вместо тихого дубликата.
304
+ */
305
+ export function getRetryBlockReason(sdkMethod: string, args: any[]): string | null {
306
+ const first = args[0];
307
+
308
+ // callMethod(method, params) и callListMethod(method, params, ...)
309
+ if (sdkMethod === "callMethod" || sdkMethod === "callListMethod") {
310
+ if (typeof first !== "string") return null;
311
+
312
+ // В фразу идёт имя, которое реально передали (без пробелов по краям),
313
+ // а не нормализованное: в логе полезнее видеть исходную форму
314
+ const name = first.trim();
315
+ const restriction = classifyMethod(name);
316
+
317
+ if (restriction === "creating") return creatingReason(name);
318
+ if (restriction === "reserving") return reservingReason(name);
319
+
320
+ return null;
321
+ }
322
+
323
+ if (sdkMethod !== "callBatch") return null;
324
+
325
+ // callBatch(calls, ...) — команды приходят массивом либо объектом-словарём
326
+ if (!first || typeof first !== "object") {
327
+ return creatingReason("аргументы batch не разобраны");
328
+ }
329
+
330
+ const commands: unknown[] = Array.isArray(first) ? first : Object.values(first);
331
+ const creating: string[] = [];
332
+ const reserving: string[] = [];
333
+
334
+ for (const cmd of commands) {
335
+ const method = extractBatchCommandMethod(cmd);
336
+
337
+ if (method === null) return creatingReason("команда batch не разобрана");
338
+
339
+ const name = method.trim();
340
+ const restriction = classifyMethod(name);
341
+
342
+ if (restriction === "creating") creating.push(name);
343
+ if (restriction === "reserving") reserving.push(name);
344
+ }
345
+
346
+ return joinReasons(creating, reserving);
347
+ }
348
+
349
+ /**
350
+ * Общий цикл повторов при сетевых ошибках.
351
+ *
352
+ * Из цикла есть три выхода без повтора:
353
+ *
354
+ * 1. ошибка не сетевая — уходит наружу как есть, без записи в лог;
355
+ * 2. провал обмена refresh-токена, не доказанный pre-connection — строка error и бросок:
356
+ * повтор небезопасен, потому что соединение состоялось, сервер мог провести ротацию,
357
+ * и тогда локальный refresh-токен мёртв независимо от повтора. Транзиентные отказы
358
+ * oauth-хоста (429, 503) под правило попадают тоже — различить их от ротации
359
+ * по ответу нельзя;
360
+ * 3. действует ограничение (blockReason) и pre-connection не доказан — строка error
361
+ * и бросок.
362
+ *
363
+ * Доказанный pre-connection (`isPreConnectionError`) снимает ограничение: DNS не
364
+ * разрешился или хост отверг соединение, значит соединение с целевым хостом не состоялось
365
+ * и повтор дубликата создать не может. Это единственное основание повторить то, что
366
+ * повторять вообще-то нельзя, — то же правило действует и в обмене refresh-токена.
367
+ *
368
+ * @param fn — вызов без аргументов, уже замкнутый на нужные параметры
369
+ * @param label — префикс лог-строк, по нему в логе видно источник повтора
370
+ * @param blockReason — готовая фраза причины, по которой повтор запрещён, либо null
371
+ */
372
+ export async function runWithRetry<T>(fn: () => Promise<T>, label: string, blockReason: string | null): Promise<T> {
373
+ let lastError: unknown;
374
+
375
+ for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
376
+ try {
377
+ return await fn();
378
+ } catch (error: unknown) {
379
+ lastError = error;
380
+
381
+ // Ошибка не сетевая (бизнес-логика B24, неверные параметры) — отдаём
382
+ // вызывающему коду как есть, он решает, что с ней делать
383
+ if (!isB24NetworkError(error)) throw error;
384
+
385
+ const msg = error instanceof Error ? error.message : String(error);
386
+ const code = error instanceof SdkError ? ` [${error.code}]` : "";
387
+
388
+ // Соединение с целевым хостом не состоялось: повторять безопасно
389
+ // даже создающий вызов
390
+ const proven = isPreConnectionError(error);
391
+
392
+ // Окончательные отказы логируем на error: собственные модули пакета
393
+ // (errorB24, Event, Smsgold) ошибку только возвращают вызывающему коду,
394
+ // но не пишут в лог — без этих строк сбой $b24 не виден нигде
395
+
396
+ // Ветка обмена токена стоит до проверки исчерпания попыток: иначе она
397
+ // сработала бы только на пятой попытке, ради чего всё и затевалось
398
+ if (!proven && isTokenRefreshFailure(error)) {
399
+ logs.add(`${label}${code}: обновление токена не удалось (${refreshFailureCode(error)}), повтор небезопасен: сервер мог провести ротацию — ${msg}`, "error");
400
+ throw error;
401
+ }
402
+
403
+ if (attempt === RETRY_COUNT) {
404
+ logs.add(`${label}${code}: исчерпаны все ${RETRY_COUNT} попыток — ${msg}`, "error");
405
+ throw error;
406
+ }
407
+
408
+ // Обрыв и таймаут не говорят, дошёл ли запрос до портала: ответ мог
409
+ // потеряться уже после того, как портал его выполнил. Различить нельзя,
410
+ // поэтому вызовы под ограничением не повторяем — лучше вернуть ошибку,
411
+ // чем создать дубликат или потерять зарезервированный пакет
412
+ if (blockReason && !proven) {
413
+ logs.add(`${label}${code}: повтор отменён, ${blockReason} — ${msg}`, "error");
414
+ throw error;
415
+ }
416
+
417
+ // Начало строки менять нельзя: по «попытка N/5 не удалась» грепают в логах
418
+ const lifted = blockReason ? ` — соединение не состоялось, ограничение (${blockReason}) снято` : "";
419
+
420
+ // Промежуточные попытки — warn: в чат B24 уходит только уровень error,
421
+ // иначе один упавший вызов дал бы пять сообщений
422
+ logs.add(`${label}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс${lifted}`, "warn");
423
+
424
+ await delay(RETRY_DELAY_MS);
425
+ }
426
+ }
427
+
428
+ // Недостижимо при RETRY_COUNT >= 1, нужно для TypeScript
429
+ throw lastError;
430
+ }
431
+
432
+ /** Оборачивает async-функцию retry-логикой при сетевых ошибках */
433
+ export function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: any, methodName: string): T {
434
+ return (async (...args: any[]) => {
435
+ // Состав вызова между попытками не меняется — разбираем один раз
436
+ const blockReason = getRetryBlockReason(methodName, args);
437
+ return runWithRetry(() => fn.apply(context, args), `$b24.${methodName}`, blockReason);
438
+ }) as T;
439
+ }
440
+
441
+ /**
442
+ * Прогоняет произвольный вызов `actions.*` через тот же retry и гейт идемпотентности,
443
+ * что и методы фасада. Прямой `$b24.actions.v3.call.make()` идёт мимо защиты — если она
444
+ * нужна (например, ради `FilterV3` или keyset-пагинации), вызов оборачивают этой функцией.
445
+ *
446
+ * @param run — сам вызов, например `() => $b24.actions.v3.call.make({ method, params })`
447
+ * @param methodName — имя REST-метода B24 (не метода SDK) либо список имён, если внутри
448
+ * batch: по ним гейт решает, создаёт ли вызов сущность и не входит ли
449
+ * он в список методов, повтор которых теряет данные
450
+ *
451
+ * @example
452
+ * const response = await callProtected(
453
+ * () => $b24!.actions.v3.call.make({ method: "crm.item.list", params }),
454
+ * "crm.item.list"
455
+ * );
456
+ *
457
+ * @example
458
+ * const response = await callProtected(
459
+ * () => $b24!.actions.v3.batch.make({ calls }),
460
+ * ["crm.item.get", "crm.item.add"]
461
+ * );
462
+ */
463
+ export async function callProtected<T>(run: () => Promise<T>, methodName: string | string[]): Promise<T> {
464
+ const names = (Array.isArray(methodName) ? methodName : [methodName]).map((name) => String(name).trim()).filter((name) => name.length > 0);
465
+
466
+ // Fail-closed, как в гейте callBatch: список пуст или имя не похоже на REST-метод
467
+ // (легальные имена всегда с точкой — crm.deal.add, disk.folder.uploadfile) — считаем
468
+ // вызов создающим. Иначе callProtected(() => batch.make({ calls }), "batch") прошёл бы
469
+ // мимо гейта, и батч с crm.deal.add внутри повторился бы до пяти раз при status 0.
470
+ const isUnparsed = names.length === 0 || names.some((name) => !name.includes("."));
471
+
472
+ const creating = names.filter((name) => classifyMethod(name) === "creating");
473
+ const reserving = names.filter((name) => classifyMethod(name) === "reserving");
474
+
475
+ const blockReason = isUnparsed ? creatingReason("имя метода не разобрано") : joinReasons(creating, reserving);
476
+
477
+ return runWithRetry(run, `actions:${names.join(", ") || "имя не указано"}`, blockReason);
478
+ }
@@ -0,0 +1,39 @@
1
+ import type { B24OAuth } from "@bitrix24/b24jssdk";
2
+ import type { B24Client } from "./types.ts";
3
+
4
+ // ==================== Состояние экземпляра ====================
5
+
6
+ /**
7
+ * @module
8
+ * Владелец обоих слоёв экземпляра B24.
9
+ *
10
+ * Модуль-лист: не импортирует ничего, кроме типов. Так решается цикл, который иначе
11
+ * возник бы между `tokens.ts` и `b24.ts` — первый читает `$b24` в `refreshAuthWithMutex`,
12
+ * второй переприсваивает его в `reinitializeB24()`.
13
+ *
14
+ * ESM отдаёт живые связывания, и они переживают два реэкспорта подряд
15
+ * (`state.ts` → `b24.ts` → `index.ts`), поэтому переприсваивание видно потребителю
16
+ * без единой правки на его стороне.
17
+ */
18
+
19
+ /** Обёрнутый Proxy экземпляр — то, с чем работает потребитель */
20
+ export let $b24: B24Client | null = null;
21
+
22
+ /** Оригинальный B24OAuth без Proxy: на него вешается setCallbackRefreshAuth */
23
+ export let b24Raw: B24OAuth | null = null;
24
+
25
+ /**
26
+ * Единственная точка записи обоих слоёв.
27
+ *
28
+ * Сегодня `b24Raw` в коде пакета не читает никто: `b24.ts` вешает
29
+ * `setCallbackRefreshAuth` на локальную `raw` / `initialRaw` — тот же объект, но своя
30
+ * переменная. Поле держит ссылку на неупакованный экземпляр и читается тестом
31
+ * (`tests/state-binding.test.ts`).
32
+ *
33
+ * Единый сеттер нужен на будущее: с раздельными сеттерами читатель `b24Raw` мог бы
34
+ * получить слои от разных экземпляров — `$b24` от прежнего рядом с новым `b24Raw`.
35
+ */
36
+ export function setB24Instance(raw: B24OAuth | null, client: B24Client | null): void {
37
+ b24Raw = raw;
38
+ $b24 = client;
39
+ }