@andrey4emk/npm-app-back-b24 3.4.0 → 3.6.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.
package/README.md CHANGED
@@ -26,6 +26,24 @@
26
26
  npm install @andrey4emk/npm-app-back-b24
27
27
  ```
28
28
 
29
+ ### Точки входа
30
+
31
+ | Импорт | Что отдаёт | Поднимает OAuth-модуль |
32
+ | --------------------------------------------- | ----------------------------- | ---------------------- |
33
+ | `@andrey4emk/npm-app-back-b24` | всё | да |
34
+ | `@andrey4emk/npm-app-back-b24/logs` | `logs` | нет |
35
+ | `@andrey4emk/npm-app-back-b24/fetchRetry` | `fetchRetry`, `isNetworkError`, `isPreConnectionError`, `maskUrl` | нет |
36
+
37
+ Корневой импорт — barrel: он тянет модуль OAuth, который при загрузке читает `authB24.json` и, если приложение авторизовано, запускает таймер проактивного обновления токена. Проектам, которые ходят в B24 по **входящему вебхуку** или берут из пакета только логгер и `fetchRetry`, удобнее импортировать по подпутям — тогда OAuth-модуль не загружается вообще.
38
+
39
+ ```js
40
+ // Проект без OAuth: ничего лишнего не поднимается
41
+ import { logs } from "@andrey4emk/npm-app-back-b24/logs";
42
+ import { fetchRetry, maskUrl } from "@andrey4emk/npm-app-back-b24/fetchRetry";
43
+ ```
44
+
45
+ Корневой импорт при этом остаётся безопасным: если `APP_B24_CLIENT_ID` и `APP_B24_CLIENT_SECRET` не заданы, пакет считает, что OAuth в проекте не используется, молча выставляет `$b24 = null` и пишет об этом только в `debug`.
46
+
29
47
  ## Использование
30
48
 
31
49
  ```js
@@ -46,6 +64,7 @@ import {
46
64
  logs,
47
65
  fetchRetry,
48
66
  isNetworkError,
67
+ maskUrl,
49
68
  } from "@andrey4emk/npm-app-back-b24";
50
69
 
51
70
  // $b24 — готовый экземпляр B24OAuth (или null, если токены не настроены)
@@ -116,6 +135,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
116
135
  **Особенности:**
117
136
  - При обновлении токенов SDK автоматически сохраняет их в файл через callback `setCallbackRefreshAuth`.
118
137
  - Окружение (`DEV`/`PROD`) определяется переменной `APP_ENV` — используется как ключ секции в конфиге авторизации.
138
+ - Признак того, что проект использует OAuth, — заданные `APP_B24_CLIENT_ID` и `APP_B24_CLIENT_SECRET`. Если их нет, `$b24` молча становится `null` (уровень `debug`). Если они заданы, но токенов в `authB24.json` не хватает, пишется `error` — это уже настоящая проблема конфигурации.
119
139
 
120
140
  **Retry при сетевых ошибках:**
121
141
 
@@ -126,7 +146,11 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
126
146
  | Попыток | 5 |
127
147
  | Задержка | 500 мс (линейно растущая для refreshAuth) |
128
148
 
129
- Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток. Каждая неудачная попытка логируется через `logs.add()` с уровнем `error` — в сообщение попадает код ошибки SDK.
149
+ Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток. Каждая неудачная попытка логируется через `logs.add()` с уровнем `warn` — в сообщение попадает код ошибки SDK. Уровень `warn` выбран намеренно: `error` уходит в чат B24, и пять попыток одного упавшего вызова превращались в пять сообщений. Финальный провал приходит вызывающему коду исключением и логируется им один раз.
150
+
151
+ **Создающие вызовы не повторяются.** Если в вызове есть метод, создающий сущность (`*.add`, `*.uploadfile`, `*.import`, `*.register` — в том числе внутри `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: при ответе портала «200 без поля `result`» (заглушка прокси или WAF) SDK падает в собственной ветке логирования и отдаёт ошибку со `status: 0`, неотличимую от транспортного сбоя — хотя запрос уже выполнен. Повтор в такой ситуации создавал до пяти дубликатов задачи или файла. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
152
+
153
+ **Обмен refresh-токена** повторяется по более строгому правилу — только если соединение заведомо не состоялось (`ENOTFOUND`, `ECONNREFUSED`, `EAI_AGAIN`, `ENETUNREACH`, см. `isPreConnectionError()`). При таймауте или обрыве сервер мог уже провести ротацию, а ответ потеряться: повтор вернул бы `invalid_grant` и оставил в конфиге мёртвый `refresh_token`.
130
154
 
131
155
  У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но он работает только при rate limit (HTTP 429, 503) и при распознанных `NETWORK_ERROR` / `REQUEST_TIMEOUT`. Замаскированные транспортные сбои приходят с кодом `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` — на таких ошибках SDK делает одну попытку, и повторяет только Proxy.
132
156
 
@@ -633,6 +657,29 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
633
657
 
634
658
  - **`fetchRetry(url, options?, retries?, delay?)`** — обёртка над `fetch` с повторными попытками при сетевых ошибках. HTTP-ошибки (4xx, 5xx) **не** вызывают повторных попыток — повторяются только сетевые сбои (TypeError, ECONNRESET, ETIMEDOUT и т.д.).
635
659
 
660
+ - **`maskUrl(url)`** — маскирует секреты в адресе перед записью в лог. URL входящего вебхука B24 содержит секрет прямо в пути (`/rest/1/<секрет>/method.json`), а токен авторизации может приходить в query-параметрах (`auth`, `access_token`, `refresh_token`, `token`). Хост и имя метода сохраняются, тело секрета заменяется на `***`. Используется внутри `fetchRetry`; применяйте в своём коде везде, где логируете адреса запросов к B24.
661
+
662
+ ```js
663
+ import { maskUrl } from "@andrey4emk/npm-app-back-b24";
664
+
665
+ logs.add(`Запрос не прошёл — ${maskUrl(url)}`, "warn");
666
+ // https://portal.bitrix24.ru/rest/1/***/crm.deal.get.json
667
+ ```
668
+
669
+ - **`isPreConnectionError(error)`** — строгая проверка: соединение не состоялось, значит запрос **точно** не был выполнен сервером (`ENOTFOUND`, `ECONNREFUSED`, `EAI_AGAIN`, `ENETUNREACH`; код ищется и во вложенных `originalError` / `cause`). Отличие от `isNetworkError()` в том, что таймаут и обрыв соединения тоже сетевые, но при них ответ мог потеряться уже после обработки запроса. Используйте эту проверку там, где повтор небезопасен сам по себе: создание сущностей, отправка сообщений, обмен токена.
670
+
671
+ ```js
672
+ import { isPreConnectionError } from "@andrey4emk/npm-app-back-b24";
673
+
674
+ try {
675
+ await sendSms(phone, text);
676
+ } catch (error) {
677
+ // Повторяем только если сообщение заведомо не ушло
678
+ if (isPreConnectionError(error)) await sendSms(phone, text);
679
+ else throw error;
680
+ }
681
+ ```
682
+
636
683
  - **`isNetworkError(error)`** — проверяет, является ли ошибка сетевой (стоит повторить запрос). Используется внутри `fetchRetry` и retry-обёртки `$b24`. Можно использовать в своём коде для аналогичных проверок.
637
684
 
638
685
  ```js
@@ -661,7 +708,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
661
708
  - **Обрабатываемые сетевые ошибки:**
662
709
  `ECONNRESET`, `ECONNREFUSED`, `ETIMEDOUT`, `ENOTFOUND`, `ENETUNREACH`, `EAI_AGAIN`, `UND_ERR_CONNECT_TIMEOUT`, `UND_ERR_SOCKET`, `TypeError`, а также ошибки с сообщениями `fetch failed`, `network`, `socket`.
663
710
 
664
- - **Логирование:** при каждой неудачной попытке записывает сообщение через `logs.add()` с уровнем `error`.
711
+ - **Логирование:** при каждой неудачной попытке записывает сообщение через `logs.add()` с уровнем `warn`. Адрес запроса перед записью пропускается через `maskUrl()`, поэтому секрет вебхука в лог не попадает.
665
712
 
666
713
  ```js
667
714
  import { fetchRetry } from "@andrey4emk/npm-app-back-b24";
package/bitrix24/b24.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { B24OAuth, AjaxError, SdkError, Logger } from "@bitrix24/b24jssdk";
2
2
  import type { B24OAuthParams, B24OAuthSecret, AuthData, AjaxResult } from "@bitrix24/b24jssdk";
3
3
  import { logs } from "../logs/logs.ts";
4
- import { isNetworkError } from "../utils/fetchRetry.ts";
4
+ import { isNetworkError, isPreConnectionError } from "../utils/fetchRetry.ts";
5
5
  import Conf from "conf";
6
6
  import path from "path";
7
7
  import type { Request, Response } from "express";
@@ -37,11 +37,22 @@ const RETRY_DELAY_MS = 500;
37
37
  /** Методы B24OAuth, оборачиваемые retry-логикой (fetchListMethod — async-генератор, его оборачивать нельзя) */
38
38
  const RETRYABLE_METHODS = new Set(["callMethod", "callListMethod", "callBatch"]);
39
39
 
40
- /** Коды AxiosError, означающие проблему с сетью */
41
- const AXIOS_NETWORK_CODES = new Set(["ERR_NETWORK", "ECONNABORTED"]);
40
+ /**
41
+ * Коды транспортного сбоя. Один набор и для SdkError (AjaxError, RefreshTokenError),
42
+ * и для вложенного AxiosError — списки совпадали, держать их раздельно смысла нет.
43
+ */
44
+ const NETWORK_ERROR_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT", "ERR_NETWORK", "ECONNABORTED"]);
42
45
 
43
- /** Коды ошибок SDK (AjaxError / RefreshTokenError), означающие транспортный сбой */
44
- const SDK_NETWORK_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT", "ERR_NETWORK", "ECONNABORTED"]);
46
+ /**
47
+ * REST-методы B24, повтор которых создаёт дубликат сущности.
48
+ *
49
+ * SDK 2.x при ответе портала «200 без поля result» (заглушка прокси или WAF) падает
50
+ * внутри собственной логирующей ветки и отдаёт ошибку со status 0 — неотличимую от
51
+ * транспортного сбоя. Запрос при этом уже выполнен, поэтому retry создаст вторую
52
+ * задачу, второй файл и т.д. Идемпотентные записи (update, delete, set) повторять
53
+ * безопасно: повторное применение даёт то же состояние.
54
+ */
55
+ const NON_IDEMPOTENT_METHOD_RE = /\.(add|uploadfile|import|register)(\?|$)/i;
45
56
 
46
57
  const confAuthB24 = new Conf({
47
58
  cwd: path.resolve(CONFIG_DIR),
@@ -86,11 +97,12 @@ export function getResultData<T = any>(response: AjaxResult, methodName: string)
86
97
  // ==================== Создание экземпляра B24OAuth ====================
87
98
 
88
99
  function createB24Instance(): B24OAuth | null {
89
- const store = confAuthB24.store as Record<string, AuthData>;
90
- const authConfig = store[APP_ENV];
91
-
92
- if (!authConfig?.domain || !authConfig?.access_token || !authConfig?.refresh_token) {
93
- logs.add( конфиге authB24 не хватает данных для авторизации. Сохрани токены через клиент и перезагрузи докер", "error");
100
+ // Проект вообще не использует OAuth: ходит в B24 по входящему вебхуку либо берёт
101
+ // из пакета только logs/fetchRetry. Отсутствие авторизации для него — норма, а не
102
+ // ошибка, поэтому уровень debug: иначе ложная строка попадает в мониторинг.
103
+ // Признак «OAuth задуман» наличие credentials приложения в .env.
104
+ if (!CLIENT_ID && !CLIENT_SECRET) {
105
+ logs.add("OAuth Bitrix24 не настроен (APP_B24_CLIENT_ID и APP_B24_CLIENT_SECRET не заданы), $b24 = null", "debug");
94
106
  return null;
95
107
  }
96
108
 
@@ -99,6 +111,14 @@ function createB24Instance(): B24OAuth | null {
99
111
  return null;
100
112
  }
101
113
 
114
+ const store = confAuthB24.store as Record<string, AuthData>;
115
+ const authConfig = store[APP_ENV];
116
+
117
+ if (!authConfig?.domain || !authConfig?.access_token || !authConfig?.refresh_token) {
118
+ logs.add("В конфиге authB24 не хватает данных для авторизации. Сохрани токены через клиент и перезагрузи докер", "error");
119
+ return null;
120
+ }
121
+
102
122
  const domain = cleanDomain(authConfig.domain);
103
123
 
104
124
  const authParams: B24OAuthParams = {
@@ -221,11 +241,17 @@ async function refreshAuthWithMutex(): Promise<AuthData> {
221
241
  return await $b24.auth.refreshAuth();
222
242
  } catch (error) {
223
243
  lastError = error;
224
- if (!isB24NetworkError(error) || attempt === RETRY_COUNT) throw error;
244
+
245
+ // Повторяем только когда соединение заведомо не состоялось: при таймауте
246
+ // или обрыве сервер мог уже провести ротацию, ответ потеряться, и повтор
247
+ // вернёт invalid_grant — refresh-токен окажется сожжён, потребуется
248
+ // ручная переавторизация
249
+ if (!isPreConnectionError(error) || attempt === RETRY_COUNT) throw error;
250
+
225
251
  const msg = error instanceof Error ? error.message : String(error);
226
252
  const code = error instanceof SdkError ? ` [${error.code}]` : "";
227
253
  const delayMs = RETRY_DELAY_MS * attempt;
228
- logs.add(`refreshAuth${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${delayMs}мс`, "error");
254
+ logs.add(`refreshAuth${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${delayMs}мс`, "warn");
229
255
  await delay(delayMs);
230
256
  }
231
257
  }
@@ -312,19 +338,48 @@ function isB24NetworkError(error: unknown): boolean {
312
338
 
313
339
  if (error instanceof SdkError) {
314
340
  const { code, originalError, status } = error;
341
+ const originalCode = (originalError as { code?: string } | undefined)?.code;
315
342
 
316
- if (SDK_NETWORK_CODES.has(code)) return true;
343
+ if (NETWORK_ERROR_CODES.has(code)) return true;
317
344
  if (originalError && isNetworkError(originalError)) return true;
318
- if (AXIOS_NETWORK_CODES.has((originalError as any)?.code)) return true;
345
+ if (originalCode && NETWORK_ERROR_CODES.has(originalCode)) return true;
319
346
  if (status === 0) return true;
320
347
  }
321
348
 
322
349
  return isNetworkError(error);
323
350
  }
324
351
 
352
+ /** Достаёт имена REST-методов B24 из аргументов вызова SDK */
353
+ function extractB24Methods(sdkMethod: string, args: any[]): string[] {
354
+ const first = args[0];
355
+
356
+ // callMethod(method, params) и callListMethod(method, params, ...)
357
+ if (sdkMethod === "callMethod" || sdkMethod === "callListMethod") {
358
+ return typeof first === "string" ? [first] : [];
359
+ }
360
+
361
+ // callBatch(calls, ...) — команды приходят массивом либо объектом-словарём
362
+ if (sdkMethod === "callBatch") {
363
+ const commands: unknown[] = Array.isArray(first) ? first : first && typeof first === "object" ? Object.values(first) : [];
364
+
365
+ return commands
366
+ .map((cmd) => (typeof cmd === "string" ? cmd : (cmd as { method?: unknown })?.method))
367
+ .filter((method): method is string => typeof method === "string");
368
+ }
369
+
370
+ return [];
371
+ }
372
+
373
+ /** Возвращает методы вызова, повтор которых создал бы дубликаты сущностей */
374
+ function getNonIdempotentMethods(sdkMethod: string, args: any[]): string[] {
375
+ return extractB24Methods(sdkMethod, args).filter((method) => NON_IDEMPOTENT_METHOD_RE.test(method));
376
+ }
377
+
325
378
  /** Оборачивает async-функцию retry-логикой при сетевых ошибках */
326
379
  function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: any, methodName: string): T {
327
380
  return (async (...args: any[]) => {
381
+ // Состав вызова между попытками не меняется — разбираем один раз
382
+ const nonIdempotent = getNonIdempotentMethods(methodName, args);
328
383
  let lastError: unknown;
329
384
 
330
385
  for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
@@ -339,7 +394,21 @@ function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: a
339
394
 
340
395
  const msg = error instanceof Error ? error.message : String(error);
341
396
  const code = error instanceof SdkError ? ` [${error.code}]` : "";
342
- logs.add(`$b24.${methodName}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс`, "error");
397
+
398
+ // Ошибка со status 0 может означать и «запрос не ушёл», и «запрос выполнен,
399
+ // а ответ не разобрался»: различить их нельзя, поэтому создающие вызовы
400
+ // не повторяем — лучше вернуть ошибку, чем создать дубликат
401
+ if (nonIdempotent.length > 0) {
402
+ logs.add(
403
+ `$b24.${methodName}${code}: повтор отменён, вызов создаёт сущности (${nonIdempotent.join(", ")}) — ${msg}`,
404
+ "warn"
405
+ );
406
+ throw error;
407
+ }
408
+
409
+ // Уровень warn: промежуточные попытки не должны улетать в чат B24,
410
+ // финальный провал прилетит вызывающему коду исключением
411
+ logs.add(`$b24.${methodName}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс`, "warn");
343
412
 
344
413
  await delay(RETRY_DELAY_MS);
345
414
  }
@@ -410,7 +479,7 @@ export async function reinitializeB24(): Promise<SaveResult> {
410
479
  $b24 = _b24Raw ? wrapB24WithRetry(_b24Raw) : null;
411
480
 
412
481
  if (!_b24Raw || !$b24) {
413
- return { error: true, message: "Не удалось пересоздать $b24 — проверь authB24.json" };
482
+ return { error: true, message: "Не удалось пересоздать $b24 — проверь authB24.json и APP_B24_CLIENT_ID/APP_B24_CLIENT_SECRET" };
414
483
  }
415
484
 
416
485
  setupRefreshCallback(_b24Raw);
@@ -426,5 +495,12 @@ export async function reinitializeB24(): Promise<SaveResult> {
426
495
 
427
496
  if (_b24Raw) {
428
497
  setupRefreshCallback(_b24Raw);
429
- refreshAndSaveTokens().then(() => startProactiveRefresh());
498
+
499
+ // Намеренно не ждём результат: импорт модуля не должен блокироваться сетевым
500
+ // запросом к oauth.bitrix.info. refreshAndSaveTokens() исключений не бросает —
501
+ // ошибку возвращает в SaveResult и пишет в лог сам
502
+ void (async () => {
503
+ await refreshAndSaveTokens();
504
+ startProactiveRefresh();
505
+ })();
430
506
  }
@@ -154,10 +154,13 @@ export class Event {
154
154
 
155
155
  if (allEvents.length > 0) {
156
156
  data.processId = arrOfflineEvents.process_id;
157
- data.message = allEvents.map((event) => {
158
- const messageData = event.EVENT_DATA.MESSAGES[0];
157
+ // Событие может принести несколько сообщений. Раньше бралось только
158
+ // первое, а остальные терялись безвозвратно — событие после обработки
159
+ // удаляется из очереди B24
160
+ data.message = allEvents.flatMap((event) => {
161
+ const messages = event.EVENT_DATA.MESSAGES ?? [];
159
162
 
160
- return {
163
+ return messages.map((messageData: any) => ({
161
164
  connectorId: event.EVENT_DATA.CONNECTOR,
162
165
  lineId: event.EVENT_DATA.LINE,
163
166
  chatId: messageData.chat.id,
@@ -165,7 +168,7 @@ export class Event {
165
168
  file: this.convertB24FilesToTelegramFormat(messageData.message.files),
166
169
  attachments: messageData.message.attachments || null,
167
170
  im: messageData.im || null,
168
- };
171
+ }));
169
172
  });
170
173
  }
171
174
 
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@andrey4emk/npm-app-back-b24",
3
- "version": "3.4.0",
3
+ "version": "3.6.0",
4
4
  "description": "Bitrix24 OAuth helpers for Node.js projects",
5
5
  "main": "index.ts",
6
6
  "type": "module",
7
7
  "exports": {
8
- ".": "./index.ts"
8
+ ".": "./index.ts",
9
+ "./logs": "./logs/logs.ts",
10
+ "./fetchRetry": "./utils/fetchRetry.ts"
9
11
  },
10
12
  "scripts": {
11
13
  "test": "echo \"No tests\" && exit 0",
@@ -1,5 +1,6 @@
1
1
  import type { B24OAuth } from "@bitrix24/b24jssdk";
2
2
  import { getResultData } from "../bitrix24/b24.ts";
3
+ import { fetchRetry } from "../utils/fetchRetry.ts";
3
4
 
4
5
  // ==================== Типы ====================
5
6
 
@@ -150,7 +151,10 @@ export class Smsgold {
150
151
 
151
152
  private async downloadFileToUrl(fileUrl: string): Promise<{ error: boolean; message?: string; buffer?: Buffer }> {
152
153
  try {
153
- const response = await fetch(fileUrl);
154
+ // Скачивание файла идемпотентно — повтор при сетевом сбое безопасен.
155
+ // Отправка SMS (fetch выше по коду) намеренно остаётся без retry:
156
+ // при потерянном ответе повтор отправил бы сообщение второй раз
157
+ const response = await fetchRetry(fileUrl);
154
158
  if (!response.ok) {
155
159
  throw new Error(`HTTP error! status: ${response.status}`);
156
160
  }
@@ -32,6 +32,58 @@ export function isNetworkError(error: unknown): boolean {
32
32
  return false;
33
33
  }
34
34
 
35
+ /**
36
+ * Коды, доказывающие, что соединение не было установлено: DNS не разрешился либо
37
+ * хост отверг подключение. Запрос при таких ошибках гарантированно не дошёл до сервера.
38
+ */
39
+ const PRE_CONNECTION_ERROR_CODES = ["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ENETUNREACH"];
40
+
41
+ /**
42
+ * Проверяет, что соединение не состоялось — запрос точно не был выполнен сервером.
43
+ *
44
+ * Отличается от `isNetworkError()` строгостью: таймаут и обрыв соединения тоже сетевые,
45
+ * но при них ответ мог потеряться уже после того, как сервер обработал запрос.
46
+ * Используйте эту проверку вместо `isNetworkError()` там, где повтор небезопасен сам
47
+ * по себе: обмен refresh-токена, создание сущностей, отправка сообщений.
48
+ *
49
+ * @param error — пойманная ошибка
50
+ * @returns true, если повторить запрос заведомо безопасно
51
+ */
52
+ export function isPreConnectionError(error: unknown): boolean {
53
+ if (!error || typeof error !== "object") return false;
54
+
55
+ const codes: unknown[] = [
56
+ (error as { code?: unknown }).code,
57
+ (error as { originalError?: { code?: unknown } }).originalError?.code,
58
+ (error as { cause?: { code?: unknown } }).cause?.code,
59
+ ];
60
+
61
+ return codes.some((code) => typeof code === "string" && PRE_CONNECTION_ERROR_CODES.includes(code));
62
+ }
63
+
64
+ /**
65
+ * Маскирует секреты в URL перед записью в лог.
66
+ *
67
+ * URL входящего вебхука B24 содержит секрет прямо в пути
68
+ * (`https://портал.bitrix24.ru/rest/1/<секрет>/method.json`), а токен авторизации
69
+ * может приходить в query-параметрах. Хост и имя метода сохраняются — они нужны
70
+ * для диагностики, тело секрета заменяется на `***`.
71
+ *
72
+ * @param url — адрес запроса в любом виде, принимаемом fetch
73
+ * @returns строка URL, пригодная для логирования
74
+ */
75
+ export function maskUrl(url: string | URL | Request): string {
76
+ const raw = typeof url === "string" ? url : url instanceof URL ? url.href : url.url;
77
+
78
+ return (
79
+ raw
80
+ // Секрет входящего вебхука: /rest/<id пользователя>/<секрет>/
81
+ .replace(/(\/rest\/\d+\/)[^/?#]+/gi, "$1***")
82
+ // Токены в query-параметрах
83
+ .replace(/([?&](?:auth|access_token|refresh_token|token)=)[^&#]+/gi, "$1***")
84
+ );
85
+ }
86
+
35
87
  /**
36
88
  * Обёртка над fetch с повторными попытками при сетевых ошибках.
37
89
  * Повторяет запрос только при проблемах с сетью (TypeError, ECONNRESET и т.д.),
@@ -63,7 +115,7 @@ export async function fetchRetry(
63
115
  }
64
116
 
65
117
  logs.add(
66
- `fetchRetry: попытка ${attempt}/${retries} не удалась (${lastError.message}), повтор через ${delay}мс — ${String(url)}`,
118
+ `fetchRetry: попытка ${attempt}/${retries} не удалась (${lastError.message}), повтор через ${delay}мс — ${maskUrl(url)}`,
67
119
  "warn"
68
120
  );
69
121