@andrey4emk/npm-app-back-b24 5.1.0 → 5.2.1

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
@@ -242,7 +242,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
242
242
 
243
243
  **Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказ — на `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в Телеграм-группу через ретранслятор вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
244
244
 
245
- **Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: транспортная ошибка не говорит, дошёл ли запрос до портала. Обрыв приходит и когда соединение не состоялось, и когда оно случилось после того, как портал уже принял и выполнил запрос — а повтор во втором случае создаёт вторую задачу, второй файл, второй экземпляр бизнес-процесса. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
245
+ **Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.upload`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: транспортная ошибка не говорит, дошёл ли запрос до портала. Обрыв приходит и когда соединение не состоялось, и когда оно случилось после того, как портал уже принял и выполнил запрос — а повтор во втором случае создаёт вторую задачу, второй файл, второй экземпляр бизнес-процесса. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние. Загрузка файла в чат (`imbot.v2.File.upload`, `im.v2.File.upload`) под защитой с выпуска после 5.2.0: раньше обрыв связи повторял её до пяти раз, и в чат приходили одинаковые файлы.
246
246
 
247
247
  Ключевое слово ищется **в любой позиции** имени, а не только в конце: `imconnector.send.messages` и `imconnector.send.status.delivery` тоже под гейтом. После ключевого слова обязателен разделитель (`.`, `?` или конец строки), поэтому `crm.deal.addcustom` и `landing.landing.addbytemplate` повторяются как прежде.
248
248
 
@@ -39,7 +39,7 @@ export const NETWORK_ERROR_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT",
39
39
 
40
40
  /**
41
41
  * REST-методы B24, повтор которых создаёт дубликат сущности или запускает
42
- * повторное действие: add, create, start, send, uploadfile, import, register.
42
+ * повторное действие: add, create, start, send, upload, uploadfile, import, register.
43
43
  *
44
44
  * Транспортная ошибка не говорит, дошёл ли запрос до портала: обрыв приходит
45
45
  * и когда соединение не состоялось, и когда оно оборвалось после того, как портал уже
@@ -66,8 +66,14 @@ export const NETWORK_ERROR_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT",
66
66
  * перестанет, и это осознанная плата за правило без списка исключений: потеря отметки
67
67
  * о доставке косметическая, переписку она не ломает, а ручной список молча устаревал бы
68
68
  * при появлении нового метода портала — ровно тот дефект, которым T-48 и был.
69
+ *
70
+ * Слово `upload` добавлено задачей T-73: `imbot.v2.File.upload` и `im.v2.File.upload`
71
+ * загружают файл в чат, а в перечне было только `uploadfile`, и обрыв связи давал до пяти
72
+ * одинаковых файлов в чате. Разделитель после слова по-прежнему обязателен, поэтому
73
+ * `upload` не срабатывает внутри `uploadfile` (тот ловится своим словом) и внутри
74
+ * имён вида `crm.deal.uploadcustom`.
69
75
  */
70
- export const NON_IDEMPOTENT_METHOD_RE = /\.(add|create|start|send|uploadfile|import|register)(\.|\?|$)/i;
76
+ export const NON_IDEMPOTENT_METHOD_RE = /\.(add|create|start|send|upload|uploadfile|import|register)(\.|\?|$)/i;
71
77
 
72
78
  /**
73
79
  * Методы, повтор которых теряет данные, хотя сущностей не создаёт.
@@ -133,8 +139,11 @@ export function retryDelayFor(error: unknown, attempt: number): number {
133
139
  return isNonJsonResponseError(error) ? NON_JSON_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1) : RETRY_DELAY_MS;
134
140
  }
135
141
 
142
+ /** Начало фразы про создающий вызов — по нему уровень отмены повтора отличает её от резервирующей */
143
+ const CREATING_REASON_HEAD = "вызов создаёт сущности (";
144
+
136
145
  /** Фраза для лога: повтор отменён, потому что вызов создаёт сущности */
137
- const creatingReason = (what: string): string => `вызов создаёт сущности (${what})`;
146
+ const creatingReason = (what: string): string => `${CREATING_REASON_HEAD}${what})`;
138
147
 
139
148
  /** Фраза для лога: повтор отменён, потому что вызов резервирует данные на портале */
140
149
  const reservingReason = (what: string): string => `вызов резервирует данные на портале (${what})`;
@@ -447,8 +456,16 @@ export async function runWithRetry<T>(fn: () => Promise<T>, label: string, block
447
456
  // потеряться уже после того, как портал его выполнил. Различить нельзя,
448
457
  // поэтому вызовы под ограничением не повторяем — лучше вернуть ошибку,
449
458
  // чем создать дубликат или потерять зарезервированный пакет
459
+ // Отмена только резервирующего вызова (event.offline.get) — warn: повтор потерю не
460
+ // исправит, а зависшие пакеты подбирает само приложение (b24_conector#T-97). Строки
461
+ // приложений о транспортном обрыве (socket hang up, ECONNRESET, таймаут) несут сетевой
462
+ // признак и уходят только сводкой, поэтому одиночный обрыв виден лишь в Loki — это
463
+ // принято сознательно. Строка об ответе без JSON («портал вернул не JSON: HTTP 200,
464
+ // text/html…») сетевого признака не несёт и уходит в Телеграм сразу.
465
+ // Отмена создающего остаётся error: сущность могла не создаться
450
466
  if (blockReason && !proven) {
451
- logs.add(`${label}${code}: повтор отменён, ${blockReason} — ${msg}`, "error");
467
+ const level = blockReason.includes(CREATING_REASON_HEAD) ? "error" : "warn";
468
+ logs.add(`${label}${code}: повтор отменён, ${blockReason} — ${msg}`, level);
452
469
  throw error;
453
470
  }
454
471
 
package/logs/logs.ts CHANGED
@@ -68,6 +68,33 @@ function describeSendError(error: unknown): string {
68
68
  return typeof causeLabel === "string" && causeLabel ? `${name} (${causeLabel})` : name;
69
69
  }
70
70
 
71
+ /**
72
+ * Признаки разового сетевого сбоя в тексте error-лога: обрыв, таймаут, недоступный хост.
73
+ * Проверяется текст, а не тип ошибки: строки о сбое пишут и сами приложения
74
+ * («Не удалось получить события. read ECONNRESET»), типа ошибки у логгера нет
75
+ */
76
+ const NETWORK_FAILURE_RE =
77
+ /ECONNRESET|socket hang up|ETIMEDOUT|ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|ENOTFOUND|EAI_AGAIN|UND_ERR_SOCKET|UND_ERR_CONNECT_TIMEOUT|Request timeout exceeded|fetch failed|other side closed|Error connecting to authorization server/i;
78
+
79
+ /**
80
+ * Строки с сетевым признаком, которые уходят сразу и по одной: отменённое создание
81
+ * сущности, провал обмена токена, неизвестный статус доставки, потерянное после повторов
82
+ * сообщение («[tg_1] Сообщение оператора не доставлено после 5 повторных попыток…: fetch failed»
83
+ * из b24_conector) — их разбирает человек.
84
+ * Фраза «не доставлено» выводит из сводки и вторую строку b24_conector:
85
+ * «[<ключ>] Сообщение не доставлено в Битрикс24: соединение не состоялось (id: N): getaddrinfo ENOTFOUND …»
86
+ * (reportSendFailure в server/api/telegram/b24MessageBuffer.ts, уровень error). Это тоже настоящая
87
+ * потеря сообщения, так задумано. Следствие: если портал долго недоступен, в Телеграм придёт
88
+ * по одному сообщению на каждое входящее сообщение клиента
89
+ */
90
+ const NETWORK_KEEP_RE = /вызов создаёт сущности|обновление токена не удалось|статус неизвестен|не доставлено/;
91
+
92
+ /** Окно, за которое считаются сетевые сбои, и пауза между сводками, мс */
93
+ const NETWORK_WINDOW_MS = 15 * 60_000;
94
+
95
+ /** Со скольких сетевых сбоев за окно в Телеграм уходит сводка */
96
+ const NETWORK_THRESHOLD = 3;
97
+
71
98
  /** Ключ ретранслятора — только видимые символы ASCII, без пробелов и управляющих */
72
99
  const RELAY_KEY_RE = /^[\x21-\x7e]+$/;
73
100
 
@@ -140,6 +167,12 @@ class LogsAPI {
140
167
  /** Ключ клиента ретранслятора (`LOG_RELAY_KEY`); `undefined` — канал выключен */
141
168
  private readonly relayKey: string | undefined;
142
169
 
170
+ /** Время сетевых сбоев за последние `NETWORK_WINDOW_MS`, мс */
171
+ private networkFailures: number[] = [];
172
+
173
+ /** Когда ушла последняя сводка сетевых сбоев, мс */
174
+ private lastNetworkSummaryAt = Number.NEGATIVE_INFINITY;
175
+
143
176
  constructor() {
144
177
  this.initConfig();
145
178
  const url = process.env.LOG_RELAY_URL?.trim() || undefined;
@@ -189,6 +222,7 @@ class LogsAPI {
189
222
  * независимо от настройки `error.enabled` в `log.json`. Настройка `enabled`
190
223
  * управляет только выводом в консоль. Это сделано намеренно, чтобы критические
191
224
  * ошибки всегда доходили до команды даже при отключённом консольном логировании.
225
+ * Исключение — разовые сетевые сбои: они уходят сводкой, см. `routeToRelay()`.
192
226
  *
193
227
  * @param message - Текст сообщения или объект для вывода
194
228
  * @param level - Уровень логирования (trace, debug, info, warn, error)
@@ -204,7 +238,7 @@ class LogsAPI {
204
238
  // В консоль ниже уходят исходные message и jsonData — маскировка телефонов
205
239
  // касается только текста для Телеграма
206
240
  if (levelStr === "error") {
207
- this.sendToRelay(messageText, jsonData);
241
+ this.routeToRelay(messageText, jsonData);
208
242
  }
209
243
 
210
244
  // Проверяем конфиг, и если логирование отключено, то выходим
@@ -228,6 +262,36 @@ class LogsAPI {
228
262
  }
229
263
  }
230
264
 
265
+ /**
266
+ * Решает, уходит ли error-лог в Телеграм сразу.
267
+ *
268
+ * Разовый сетевой сбой проходит сам, а сообщение о нём в Телеграме — шум: 25.10.2026
269
+ * один недоступный сервер авторизации дал 14 сообщений подряд. Поэтому строка
270
+ * с сетевым признаком остаётся только в консоли, а в Телеграм уходит одна сводка,
271
+ * когда за 15 минут набралось 3 сбоя и больше; следующая — не раньше чем через 15 минут.
272
+ * Длинный обрыв так и виден — сводкой раз в 15 минут.
273
+ */
274
+ private routeToRelay(message: string, jsonData?: unknown): void {
275
+ if (!this.relayUrl || !this.relayKey) return;
276
+
277
+ if (!NETWORK_FAILURE_RE.test(message) || NETWORK_KEEP_RE.test(message)) {
278
+ this.sendToRelay(message, jsonData);
279
+ return;
280
+ }
281
+
282
+ const now = Date.now();
283
+ this.networkFailures = this.networkFailures.filter((at) => now - at < NETWORK_WINDOW_MS);
284
+ this.networkFailures.push(now);
285
+
286
+ if (this.networkFailures.length < NETWORK_THRESHOLD) return;
287
+ if (now - this.lastNetworkSummaryAt < NETWORK_WINDOW_MS) return;
288
+
289
+ this.lastNetworkSummaryAt = now;
290
+ const minutes = NETWORK_WINDOW_MS / 60_000;
291
+ const summary = `Сетевые сбои: ${this.networkFailures.length} за ${minutes} мин (одиночные в Телеграм не отправляются, все строки — в логе контейнера). Последний:\n${message}`;
292
+ this.sendToRelay(summary, jsonData);
293
+ }
294
+
231
295
  /**
232
296
  * Готовит текст error-лога и отправляет его в ретранслятор (fire-and-forget).
233
297
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrey4emk/npm-app-back-b24",
3
- "version": "5.1.0",
3
+ "version": "5.2.1",
4
4
  "description": "Bitrix24 OAuth helpers for Node.js projects",
5
5
  "main": "index.ts",
6
6
  "type": "module",