@andrey4emk/npm-app-back-b24 3.5.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
@@ -32,7 +32,7 @@ npm install @andrey4emk/npm-app-back-b24
32
32
  | --------------------------------------------- | ----------------------------- | ---------------------- |
33
33
  | `@andrey4emk/npm-app-back-b24` | всё | да |
34
34
  | `@andrey4emk/npm-app-back-b24/logs` | `logs` | нет |
35
- | `@andrey4emk/npm-app-back-b24/fetchRetry` | `fetchRetry`, `isNetworkError`, `maskUrl` | нет |
35
+ | `@andrey4emk/npm-app-back-b24/fetchRetry` | `fetchRetry`, `isNetworkError`, `isPreConnectionError`, `maskUrl` | нет |
36
36
 
37
37
  Корневой импорт — barrel: он тянет модуль OAuth, который при загрузке читает `authB24.json` и, если приложение авторизовано, запускает таймер проактивного обновления токена. Проектам, которые ходят в B24 по **входящему вебхуку** или берут из пакета только логгер и `fetchRetry`, удобнее импортировать по подпутям — тогда OAuth-модуль не загружается вообще.
38
38
 
@@ -146,7 +146,11 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
146
146
  | Попыток | 5 |
147
147
  | Задержка | 500 мс (линейно растущая для refreshAuth) |
148
148
 
149
- 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`.
150
154
 
151
155
  У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но он работает только при rate limit (HTTP 429, 503) и при распознанных `NETWORK_ERROR` / `REQUEST_TIMEOUT`. Замаскированные транспортные сбои приходят с кодом `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` — на таких ошибках SDK делает одну попытку, и повторяет только Proxy.
152
156
 
@@ -662,6 +666,20 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
662
666
  // https://portal.bitrix24.ru/rest/1/***/crm.deal.get.json
663
667
  ```
664
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
+
665
683
  - **`isNetworkError(error)`** — проверяет, является ли ошибка сетевой (стоит повторить запрос). Используется внутри `fetchRetry` и retry-обёртки `$b24`. Можно использовать в своём коде для аналогичных проверок.
666
684
 
667
685
  ```js
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),
@@ -230,11 +241,17 @@ async function refreshAuthWithMutex(): Promise<AuthData> {
230
241
  return await $b24.auth.refreshAuth();
231
242
  } catch (error) {
232
243
  lastError = error;
233
- 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
+
234
251
  const msg = error instanceof Error ? error.message : String(error);
235
252
  const code = error instanceof SdkError ? ` [${error.code}]` : "";
236
253
  const delayMs = RETRY_DELAY_MS * attempt;
237
- logs.add(`refreshAuth${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${delayMs}мс`, "error");
254
+ logs.add(`refreshAuth${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${delayMs}мс`, "warn");
238
255
  await delay(delayMs);
239
256
  }
240
257
  }
@@ -321,19 +338,48 @@ function isB24NetworkError(error: unknown): boolean {
321
338
 
322
339
  if (error instanceof SdkError) {
323
340
  const { code, originalError, status } = error;
341
+ const originalCode = (originalError as { code?: string } | undefined)?.code;
324
342
 
325
- if (SDK_NETWORK_CODES.has(code)) return true;
343
+ if (NETWORK_ERROR_CODES.has(code)) return true;
326
344
  if (originalError && isNetworkError(originalError)) return true;
327
- if (AXIOS_NETWORK_CODES.has((originalError as any)?.code)) return true;
345
+ if (originalCode && NETWORK_ERROR_CODES.has(originalCode)) return true;
328
346
  if (status === 0) return true;
329
347
  }
330
348
 
331
349
  return isNetworkError(error);
332
350
  }
333
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
+
334
378
  /** Оборачивает async-функцию retry-логикой при сетевых ошибках */
335
379
  function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: any, methodName: string): T {
336
380
  return (async (...args: any[]) => {
381
+ // Состав вызова между попытками не меняется — разбираем один раз
382
+ const nonIdempotent = getNonIdempotentMethods(methodName, args);
337
383
  let lastError: unknown;
338
384
 
339
385
  for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
@@ -348,7 +394,21 @@ function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: a
348
394
 
349
395
  const msg = error instanceof Error ? error.message : String(error);
350
396
  const code = error instanceof SdkError ? ` [${error.code}]` : "";
351
- 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");
352
412
 
353
413
  await delay(RETRY_DELAY_MS);
354
414
  }
@@ -435,5 +495,12 @@ export async function reinitializeB24(): Promise<SaveResult> {
435
495
 
436
496
  if (_b24Raw) {
437
497
  setupRefreshCallback(_b24Raw);
438
- refreshAndSaveTokens().then(() => startProactiveRefresh());
498
+
499
+ // Намеренно не ждём результат: импорт модуля не должен блокироваться сетевым
500
+ // запросом к oauth.bitrix.info. refreshAndSaveTokens() исключений не бросает —
501
+ // ошибку возвращает в SaveResult и пишет в лог сам
502
+ void (async () => {
503
+ await refreshAndSaveTokens();
504
+ startProactiveRefresh();
505
+ })();
439
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrey4emk/npm-app-back-b24",
3
- "version": "3.5.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",
@@ -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,35 @@ 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
+
35
64
  /**
36
65
  * Маскирует секреты в URL перед записью в лог.
37
66
  *