@andrey4emk/npm-app-back-b24 3.7.1 → 3.8.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
@@ -15,7 +15,7 @@
15
15
  - [Email](#email)
16
16
  - [Wappi](#wappi)
17
17
  - [Логирование](#логирование)
18
- - [fetchRetry](#fetchretry)
18
+ - [fetchRetry и таймауты](#fetchretry-и-таймауты)
19
19
  - [Переменные окружения](#переменные-окружения)
20
20
  - [Скрипты](#скрипты)
21
21
  - [Лицензия](#лицензия)
@@ -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`, `isPreConnectionError`, `maskUrl` | нет |
35
+ | `@andrey4emk/npm-app-back-b24/fetchRetry` | `fetchRetry`, `fetchWithTimeout`, `isNetworkError`, `isPreConnectionError`, `isTimeoutError`, `isAbortError`, `maskUrl`, `FETCH_TIMEOUTS`, `DEFAULT_FETCH_TIMEOUT_MS` | нет |
36
36
 
37
37
  Корневой импорт — barrel: он тянет модуль OAuth, который при загрузке читает `authB24.json` и, если приложение авторизовано, запускает таймер проактивного обновления токена. Проектам, которые ходят в B24 по **входящему вебхуку** или берут из пакета только логгер и `fetchRetry`, удобнее импортировать по подпутям — тогда OAuth-модуль не загружается вообще.
38
38
 
@@ -50,6 +50,41 @@ import { fetchRetry, maskUrl } from "@andrey4emk/npm-app-back-b24/fetchRetry";
50
50
 
51
51
  Практическое следствие: индексация массива внутри пакета всегда сопровождается проверкой на `undefined`. Если в своём проекте эти флаги включены — ошибок из `node_modules/@andrey4emk/npm-app-back-b24` быть не должно.
52
52
 
53
+ ### Nuxt: сборка в Nitro и импорт `$b24`
54
+
55
+ Пакет публикуется исходниками на TypeScript, а Nitro по умолчанию не транспилирует ничего из `node_modules`. Двух ловушек здесь достаточно, чтобы потерять полдня, — обе проверены на Nuxt 4.5.
56
+
57
+ **Сборка.** Одного `nitro.externals.inline` мало: Nuxt жёстко ставит `esbuild.options.exclude = [/node_modules/]`, а `defu` при слиянии конфигов массивы **склеивает**, а не заменяет — переопределить это из `nuxt.config` нельзя. Rollup получает сырой TypeScript и падает на `Expected '{', got 'interface'`. Рабочая комбинация — inline плюс хук `nitro:config`, который присваивает `exclude` заново:
58
+
59
+ ```ts
60
+ // nuxt.config.ts
61
+ export default defineNuxtConfig({
62
+ nitro: { externals: { inline: ["@andrey4emk/npm-app-back-b24"] } },
63
+ hooks: {
64
+ "nitro:config"(nitroConfig) {
65
+ // ЗАМЕНЯЕТ exclude, а не дополняет — иначе /node_modules/ из дефолта победит
66
+ nitroConfig.esbuild.options.exclude = [/node_modules\/(?!@andrey4emk\/)/];
67
+ },
68
+ },
69
+ });
70
+ ```
71
+
72
+ **Импорт `$b24`.** Это `export let`, а не `const`: `reinitializeB24()` переприсваивает биндинг. Захват значения в момент импорта (`const b24 = $b24` на уровне модуля) навсегда оставит то, что было при загрузке, — как правило `null`, потому что токены к этому моменту ещё не прочитаны. Читать свойство нужно в момент вызова:
73
+
74
+ ```ts
75
+ // server/utils/b24.ts
76
+ import { $b24 } from "@andrey4emk/npm-app-back-b24";
77
+
78
+ // Геттер, а не const: биндинг $b24 меняется после reinitializeB24()
79
+ export const getB24 = () => $b24;
80
+
81
+ // Использование
82
+ const b24 = getB24();
83
+ if (b24) {
84
+ const res = await b24.callMethod("crm.deal.get", { id: 123 });
85
+ }
86
+ ```
87
+
53
88
  ## Использование
54
89
 
55
90
  ```js
@@ -70,8 +105,11 @@ import {
70
105
  Wappi,
71
106
  logs,
72
107
  fetchRetry,
108
+ fetchWithTimeout,
73
109
  isNetworkError,
110
+ isTimeoutError,
74
111
  maskUrl,
112
+ FETCH_TIMEOUTS,
75
113
  } from "@andrey4emk/npm-app-back-b24";
76
114
 
77
115
  // $b24 — готовый экземпляр B24OAuth (или null, если токены не настроены)
@@ -119,8 +157,11 @@ await wappi.sendMessageWappi("whatsApp", { phone: "+1234567890", message: "Пр
119
157
  // Логирование
120
158
  logs.add("Сообщение", "info");
121
159
 
122
- // fetch с повторными попытками при сетевых ошибках
160
+ // fetch с повторными попытками при сетевых ошибках (бюджет попытки — 60с)
123
161
  const response = await fetchRetry("https://api.example.com/data", { method: "GET" });
162
+
163
+ // Один запрос без повторов — для неидемпотентных операций
164
+ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "POST" }, FETCH_TIMEOUTS.send);
124
165
  ```
125
166
 
126
167
  ## API
@@ -167,15 +208,21 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
167
208
 
168
209
  **Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказ — на `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в чат B24 вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
169
210
 
170
- **Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: при ответе портала «200 без поля `result`» (заглушка прокси или WAF) SDK падает в собственной ветке логирования и отдаёт ошибку со `status: 0`, неотличимую от транспортного сбоя — хотя запрос уже выполнен. Повтор в такой ситуации создавал до пяти дубликатов задачи или файла. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
211
+ **Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: транспортная ошибка не говорит, дошёл ли запрос до портала. `NETWORK_ERROR` приходит и когда соединение не состоялось, и когда оно оборвалось после того, как портал уже принял и выполнил запрос — а повтор во втором случае создаёт вторую задачу, второй файл, второй экземпляр бизнес-процесса. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
171
212
 
172
213
  Команды `callBatch` разбираются в обеих формах — кортеж `["crm.deal.add", {...}]` (основная в SDK 2.x) и объект `{ method, params }`. Если форму команды разобрать не удалось, вызов считается создающим и не повторяется: для защиты от дубликатов безопаснее ошибиться в сторону осторожности.
173
214
 
174
- Важно: **у SDK есть собственный слой ретраев**, который гейт не отменяет. При настоящем разрыве соединения (`ECONNRESET` и подобные) SDK сходит на портал до 3 раз независимо от нашей защиты. Гейт закрывает конкретный случай — `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` и самим SDK не повторяется.
215
+ Важно: **транспортные сбои SDK не повторяет вообще** — пакет задаёт параметры ограничителя при создании клиента. Иначе гейт был бы дырявым: SDK не различает идемпотентные и создающие вызовы и повторил бы оборванный `crm.deal.add` до трёх раз внутри себя, куда наша защита не дотягивается. Измерено: без этих параметров обрыв соединения, 502 и 504 дают по три выполнения на портале, с ними — по одному.
216
+
217
+ Повторы SDK при 429 и 503 сохранены: портал отвечает отказом, не выполнив запрос.
218
+
219
+ Отсюда следствие для вашего кода: **не вызывайте `setRestrictionManagerParams()` и не поднимайте `initB24Helper()` / `B24HelperManager` на `$b24`** — они перезаписывают настройки ограничителя целиком, не сливая, и молча вернут повторы SDK вместе с риском дубликатов.
175
220
 
176
221
  **Обмен refresh-токена** повторяется по более строгому правилу — только если соединение заведомо не состоялось (см. `isPreConnectionError()`). При таймауте или обрыве сервер мог уже провести ротацию; тогда локальный `refresh_token` мёртв независимо от наших действий, и повтор не помогает, а лишь маскирует проблему серией одинаковых отказов. Следующая попытка всё равно будет: проактивный таймер повторяет через 2 минуты при времени жизни токена около часа.
177
222
 
178
- У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но он работает только при rate limit (HTTP 429, 503) и при распознанных `NETWORK_ERROR` / `REQUEST_TIMEOUT`. Замаскированные транспортные сбои приходят с кодом `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` — на таких ошибках SDK делает одну попытку, и повторяет только Proxy.
223
+ У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но на транспортных ошибках он выключен — там одна попытка, повторяет только Proxy. При 429, 503 и неизвестных 5xx SDK повторяет по-прежнему.
224
+
225
+ Отдельно про ответы шлюза перед порталом. **502** повторяет наш слой: шлюз не смог получить ответ от upstream, запрос почти наверняка не выполнялся. **504** не повторяет никто — это «портал взял запрос и считает прямо сейчас»; повтор создающего вызова дал бы дубликат, а читающего — ничего, кроме нагрузки на портал в худший для него момент.
179
226
 
180
227
  **Диагностика SDK.** Начиная с 3.7.0 собственные сообщения SDK от уровня `WARNING` и выше попадают в лог пакета с префиксом `SDK B24:` вместе с контекстом (`requestId`, `method`, `status`, код ошибки) — например предупреждение `fetchList` про игнорируемый `order` или про остановку пагинации, когда `idKey` не совпал с полем ответа. Порог нужен: на уровне `info` SDK пишет две строки на каждый запрос. Ошибки SDK приходят как `warn`, а не `error` — это диагностика отдельной неуспешной попытки, а окончательный провал вызова один раз пишет retry-слой.
181
228
 
@@ -427,6 +474,8 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
427
474
  processId: "12345",
428
475
  message: [
429
476
  {
477
+ messageId: "7cf5d29bee90cc37382a9fff07fc2a43", // MESSAGE_ID записи очереди — ключ для clear()
478
+ eventId: "4821", // ID записи очереди, информационное; в clear() не передавать
430
479
  connectorId: "...",
431
480
  lineId: "...",
432
481
  chatId: "...",
@@ -454,6 +503,8 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
454
503
  - Автоматически фильтрует и удаляет события от системного пользователя (`user_id: '138'`).
455
504
  - Для коннекторных событий конвертирует файлы в формат с типами `image`, `video`, `document`.
456
505
  - Если событий нет, возвращает `data` с пустыми массивами.
506
+ - **Гранулярность очистки — запись очереди, а не сообщение.** Одна запись коннектора (один `MESSAGE_ID`) может нести несколько сообщений, и они приходят отдельными элементами `message` с одинаковым `messageId`. Очистка по этому ключу удаляет запись целиком: если из двух сообщений одной записи ушло одно, `clear()` заберёт и неотправленное. Строить поэлементную очистку с точностью до сообщения на `messageId` нельзя. Для дедупликации отдельных сообщений он не годится по той же причине — у сообщений одной записи он общий, для этого есть `im.id`. Обратная сторона: запись, из которой не разобрано ни одного сообщения, в `data.message` не попадёт вовсе, и очистка по собранным `messageId` её не заберёт. Безопасный режим — `clear(processId)` целиком.
507
+ - Типы `ConnectorMessage`, `ConnectorEvents` и `EntityEvents` экспортируются из пакета.
457
508
 
458
509
  ```js
459
510
  const { error, data } = await event.get("ONCRMDYNAMICITEMUPDATE_149");
@@ -467,7 +518,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
467
518
  - **Параметры:**
468
519
 
469
520
  - `processId` (string) — ID процесса (получен из `get()`).
470
- - `messageId` (string|string[]) — опциональный ID или массив ID сообщений для очистки.
521
+ - `messageId` (string|string[]) — опциональный `MESSAGE_ID` или массив `MESSAGE_ID` записей очереди для очистки. Без него очищается весь пакет `processId`. Пустой массив означает «очищать нечего»: вызов до API не доходит и возвращает `error: false` (раньше уезжало `message_id: []`). «Мягкая» ошибка портала, например `ERROR_ENTITY_NOT_FOUND` на протухшем `processId`, возвращается как `error: true`.
471
522
 
472
523
  - **Возвращает:** промис с объектом `{ error, message, data }`.
473
524
 
@@ -501,6 +552,8 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
501
552
 
502
553
  Конструктор сразу разрешает тип мессенджера для `whatsApp` и `telegram` — берёт `messenger[0].type` и держит в полях, вместо того чтобы индексировать массив при каждой отправке. Если конфига нет или список `messenger` пуст, тип остаётся неразрешённым, и **методы этого мессенджера возвращают** `{ error: true, message: "ChatApp: не разрешён тип мессенджера whatsApp (нет конфига или пуст messenger)", data: null }` — вместо запроса на URL с `undefined` в пути. Конструктор при этом **не бросает**: экземпляр обычно создаётся на уровне модуля, и бросок уронил бы загрузку всего модуля отправки вместе с соседними каналами.
503
554
 
555
+ **Таймаут отправки — статус неизвестен.** Запросы идут с бюджетом времени (10 секунд на работу с токеном, 20 на проверку номера, 30 на отправку сообщения, 60 на отправку файла). Если ответа не дождались, отправка **могла состояться** — методы возвращают `{ error: true, message: "ChatApp: таймаут ... — статус неизвестен", data: null }`, а не бросают исключение. Повторять такую отправку нельзя: клиент получит второе сообщение. Пакет дополнительно пишет строку уровня `error` (она уходит в чат B24) — решение о повторе принимает человек. Раньше зависание долетало до вызывающего кода исключением через пять минут; теперь оно возвращается объектом через тридцать секунд, поэтому запись в лог обязательна: без неё отказ стал бы тише, чем был.
556
+
504
557
  Отказ локальный: пустой `whatsApp` не мешает `telegram`. Но конфиг всё равно нужен для **обоих** мессенджеров, даже если проект пользуется одним — иначе второй канал молча перестанет работать, и узнать об этом можно будет только по `res.error` в логе.
505
558
 
506
559
  **Методы:**
@@ -564,6 +617,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
564
617
  - `messengerType` (string) — `"whatsApp"` или `"telegram"`.
565
618
  - `phone` (string) — номер телефона.
566
619
  - **Возвращает:** промис с объектом `{ error, message, data }`, где `data.check` — результат проверки.
620
+ - **Особенности:** если API ответил `success: true`, но без объекта `data`, метод возвращает `{ error: true, message: "Ответ ChatApp без поля data ...", data }` с сырым ответом. Раньше в этом случае наружу летел `TypeError` — метод не обёрнут в `try/catch`. Подставлять `check: undefined` было бы хуже: вызывающий принял бы непонятный ответ за «телефон не найден».
567
621
 
568
622
  ### Smsgold
569
623
 
@@ -574,10 +628,13 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
574
628
 
575
629
  const smsClient = new Smsgold(
576
630
  { user: "smsgold_login", pass: "smsgold_password" },
577
- b24Instance // опционально — экземпляр B24OAuth для загрузки файлов на диск
631
+ b24Instance, // опционально — экземпляр B24OAuth для загрузки файлов на диск
632
+ 123456 // опционально — ID папки на диске B24 для вложений, по умолчанию 2792881
578
633
  );
579
634
  ```
580
635
 
636
+ Третий параметр — папка, куда кладётся файл из `fileUrl` перед отправкой SMS. Дефолт `2792881` — папка «contract_company» портала `repacheb.bitrix24.ru`; на другом портале такого ID нет, и его нужно передать своим. Параметр нужен только при отправке файлов.
637
+
581
638
  **Методы:**
582
639
 
583
640
  - **`sendSms(messageData)`** — отправить SMS.
@@ -588,7 +645,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
588
645
  - `message` (string) — текст сообщения.
589
646
  - `fileUrl` (string) — опционально, ссылка на файл (будет загружен на диск Bitrix24 и публичная ссылка добавлена в SMS).
590
647
  - `fileName` (string) — опционально, имя файла.
591
- - **Возвращает:** промис с объектом `{ error, message, result }`.
648
+ - **Возвращает:** промис с объектом `{ error, message, result }`, а при таймауте — ещё и `unknownDelivery: true`.
592
649
 
593
650
  ```js
594
651
  const result = await smsClient.sendSms({
@@ -600,6 +657,19 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
600
657
  }
601
658
  ```
602
659
 
660
+ - **Таймаут и неизвестный статус доставки.** Отправка идёт без повторов с бюджетом 30 секунд. Если SMSGold не ответил, запрос уже ушёл — сообщение **могло быть доставлено**, и повторять отправку нельзя. Такой результат отличается от обычной ошибки: `unknownDelivery: true` и текст «SMSGold не ответил за 30с — статус отправки неизвестен». Дополнительно пакет пишет строку уровня `error` (она уходит в чат B24), потому что решение о повторе должен принимать человек, а не код. Это единственное место, где `Smsgold` пишет в лог сам.
661
+
662
+ ```js
663
+ const result = await smsClient.sendSms({ phone, message });
664
+ if (result.unknownDelivery) {
665
+ // Не отправлять повторно: возможен дубль у клиента. Ставим задачу на ручную проверку
666
+ } else if (result.error) {
667
+ // Обычная ошибка — отправка не состоялась, повтор безопасен
668
+ }
669
+ ```
670
+
671
+ - **Скачивание файла** по `fileUrl` идёт через `fetchRetry` с 3 попытками и бюджетом 60 секунд на попытку (раньше было 5 попыток без таймаута): пять попыток по минуте задерживали бы саму отправку SMS до пяти минут.
672
+
603
673
  ### Email
604
674
 
605
675
  - **`Email`** — класс для отправки email через SMTP Яндекса. Экземпляр нужно создать самостоятельно.
@@ -640,12 +710,15 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
640
710
  - SMTP-сервер: `smtp.yandex.ru:465` (SMTPS).
641
711
  - Копия письма автоматически отправляется на адрес отправителя.
642
712
  - Транспорт — `nodemailer` `^9` (диапазон, а не точная версия: иначе патч безопасности нельзя было бы применить на стороне потребителя).
643
- - Вложения передаются готовым `Buffer` — файл по `fileUrl` пакет скачивает сам. Поля `path` и `href` намеренно не поддерживаются: nodemailer не должен ходить по URL.
713
+ - Вложения передаются готовым `Buffer` — файл по `fileUrl` пакет скачивает сам. Поля `path` и `href` намеренно не поддерживаются: nodemailer не должен ходить по URL. Скачивание идёт через `fetchWithTimeout` с бюджетом 60 секунд.
714
+ - Таймауты SMTP-транспорта переопределены: соединение 15 с, приветствие 10 с, сокет 120 с. Дефолты nodemailer (2 мин / 30 с / 10 мин) слишком щедры — письмо на Яндекс уходит за секунды, а 10 минут молчания сокета блокируют вызывающую очередь.
644
715
 
645
716
  ### Wappi
646
717
 
647
718
  - **`Wappi`** — класс для отправки сообщений через Wappi API (WhatsApp, Telegram, Max). Экземпляр нужно создать самостоятельно.
648
719
 
720
+ **Таймаут отправки — статус неизвестен.** Как и у `ChatApp`: бюджет 20 секунд на проверку номера, 30 на сообщение, 60 на файл. При таймауте отправки метод возвращает `{ error: true, message: "Wappi: таймаут ... — статус неизвестен", data: null }` и пишет строку уровня `error` в чат B24 — сообщение могло уйти, повторять нельзя. Отдельно про Telegram: `phoneCheckWappi` при таймауте проверки контакта **не создаёт контакт**, а возвращает ошибку. Иначе не дождавшись ответа мы бы завели контакт на номер, который мог уже быть в адресной книге, и объявили номер проверенным без проверки.
721
+
649
722
  ```js
650
723
  import { Wappi } from "@andrey4emk/npm-app-back-b24";
651
724
 
@@ -720,9 +793,9 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
720
793
  - Конфигурация хранится в файле `log.json` (через `conf`).
721
794
  - Каждый уровень можно включить/выключить, изменить цвет.
722
795
 
723
- ### fetchRetry
796
+ ### fetchRetry и таймауты
724
797
 
725
- - **`fetchRetry(url, options?, retries?, delay?)`** — обёртка над `fetch` с повторными попытками при сетевых ошибках. HTTP-ошибки (4xx, 5xx) **не** вызывают повторных попыток — повторяются только сетевые сбои (TypeError, ECONNRESET, ETIMEDOUT и т.д.).
798
+ - **`fetchRetry(url, options?, retries?, delay?, timeoutMs?)`** — обёртка над `fetch` с повторными попытками при сетевых ошибках. HTTP-ошибки (4xx, 5xx) **не** вызывают повторных попыток — повторяются только сетевые сбои (TypeError, ECONNRESET, ETIMEDOUT и т.д.) и собственный таймаут запроса.
726
799
 
727
800
  - **`maskUrl(url)`** — маскирует секреты в адресе перед записью в лог. URL входящего вебхука B24 содержит секрет прямо в пути (`/rest/1/<секрет>/method.json`), а токен авторизации может приходить в query-параметрах (`auth`, `access_token`, `refresh_token`, `token`). Хост и имя метода сохраняются, тело секрета заменяется на `***`. Используется внутри `fetchRetry`; применяйте в своём коде везде, где логируете адреса запросов к B24.
728
801
 
@@ -769,6 +842,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
769
842
  | `options` | RequestInit | `undefined` | Параметры fetch (метод, заголовки, body и т.д.) |
770
843
  | `retries` | number | `5` | Количество попыток |
771
844
  | `delay` | number | `500` | Задержка между попытками (мс) |
845
+ | `timeoutMs` | number | `60000` (или `FETCH_TIMEOUT_MS`) | Бюджет на одну попытку; `0` — без таймаута |
772
846
 
773
847
  - **Возвращает:** `Promise<Response>` — ответ от fetch.
774
848
 
@@ -797,6 +871,46 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
797
871
  );
798
872
  ```
799
873
 
874
+ #### Таймауты
875
+
876
+ С версии 3.8.0 каждый запрос пакета имеет бюджет времени: зависшее соединение больше не держится до таймаутов undici (5 минут на попытку, а при медленной «капле» данных — бесконечно).
877
+
878
+ - **Бюджет считается на попытку, а не на весь вызов.** Каждой попытке `fetchRetry` выдаётся свой `AbortSignal.timeout(timeoutMs)`. Бюджет покрывает и чтение тела ответа: сигнал живёт до конца запроса, поэтому `res.text()` / `res.arrayBuffer()` могут упасть с `TimeoutError` уже **после** возврата из `fetchRetry`. Для крупных файлов передавайте больший `timeoutMs` или `0`.
879
+ - **Худший случай по времени** — `retries × (timeoutMs + delay)`. При дефолтах это 5 × (60 000 + 500) ≈ 5 минут.
880
+ - **Свой `options.signal` отключает наш дефолт.** Если вызывающий передал сигнал именно в `options`, а `timeoutMs` не задан, пакет своего таймаута не добавляет — чужой осознанный бюджет не обрезается. Явно переданный `timeoutMs` применяется всегда, тогда побеждает тот сигнал, который сработал раньше. Отмена по чужому сигналу **никогда не повторяется**; различаем её по объекту сигнала, а не по типу ошибки: причиной отмены может быть любой объект, включая `TypeError`.
881
+ - **Сигнал у объекта `Request` на дефолт не влияет.** Он участвует в отмене — сигналы склеиваются через `AbortSignal.any`, иначе `fetch(request, init)` затёр бы его, — но признаком «вызывающий сам управляет временем» не считается: у любого `Request` сигнал есть всегда, даже когда его никто не задавал. Учитывать его значило бы молча снять таймаут со всех вызовов такой формы. Нужен свой бюджет при вызове с `Request` — передавайте `timeoutMs` пятым аргументом.
882
+ - **Таймаут при чтении тела не повторяется и не логируется.** Бюджет действует до конца запроса, поэтому `res.text()` / `res.arrayBuffer()` после возврата из `fetchRetry` могут упасть с `TimeoutError` уже у вас. Функция об этом не знает: повторов там нет, записи в лог тоже. Читаете большое тело — задавайте `timeoutMs` с запасом на скачивание.
883
+ - **Переменная `FETCH_TIMEOUT_MS`** меняет дефолт без правки кода (читается один раз при загрузке модуля), `FETCH_TIMEOUT_MS=0` выключает таймаут. Это аварийный рычаг на случай, когда дефолт обрывает живой долгий запрос — отчёт Seatable, Google Sheets, МойСклад. Действует только на вызовы **без явного** `timeoutMs`: внутренние вызовы пакета (ChatApp, Wappi, SMS, скачивание файлов) передают свои значения из `FETCH_TIMEOUTS` и переменную игнорируют. Значение должно быть целым от 0 до 2 147 483 647 — непригодное отбрасывается с записью в лог, а не выключает защиту молча.
884
+ - **Ограничение при вызове с объектом `Request`:** тело такого запроса читается первой попыткой, и повтор упадёт с `body used already`. Существовало и до таймаутов; для повторяемых запросов передавайте строку URL и `options`.
885
+
886
+ - **`fetchWithTimeout(url, options?, timeoutMs?)`** — один запрос с бюджетом времени, **без повторов и без логирования**. Для неидемпотентных операций: отправка сообщения, создание сущности — там, где повтор мог бы продублировать действие, но защита от зависшего соединения нужна. Правила про чужой сигнал те же, что у `fetchRetry`. Внутри пакета через неё идут все отправки ChatApp, Wappi и SMSGold.
887
+
888
+ ```js
889
+ import { fetchWithTimeout, FETCH_TIMEOUTS } from "@andrey4emk/npm-app-back-b24/fetchRetry";
890
+
891
+ const res = await fetchWithTimeout(
892
+ "https://api.example.com/orders",
893
+ { method: "POST", body: JSON.stringify(order) },
894
+ FETCH_TIMEOUTS.send // 30 000 мс
895
+ );
896
+ ```
897
+
898
+ - **`FETCH_TIMEOUTS`** — именованные бюджеты, которыми пакет пользуется сам: `quick` 10 000 (токены), `api` 20 000 (короткое чтение), `send` 30 000 (отправка), `transfer` 60 000 (файлы). **`DEFAULT_FETCH_TIMEOUT_MS`** — действующий дефолт `fetchRetry` (60 000 либо значение `FETCH_TIMEOUT_MS`).
899
+
900
+ - **`isTimeoutError(error)` / `isAbortError(error)`** — различают «не дождались» и «отменили». Ошибка таймаута не оборачивается в свою: наружу идёт исходный `DOMException` с `name: "TimeoutError"` (отмена без причины — `"AbortError"`), проверки читают именно `name`.
901
+
902
+ ```js
903
+ import { fetchRetry, isTimeoutError, isAbortError } from "@andrey4emk/npm-app-back-b24/fetchRetry";
904
+
905
+ try {
906
+ await fetchRetry(url, { signal: ac.signal }, 3, 500, 20_000);
907
+ } catch (error) {
908
+ if (isTimeoutError(error)) logs.add("не дождались ответа", "warn");
909
+ else if (isAbortError(error)) logs.add("запрос отменили", "debug");
910
+ else throw error;
911
+ }
912
+ ```
913
+
800
914
  ## Переменные окружения
801
915
 
802
916
  | Переменная | Назначение |
@@ -807,6 +921,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
807
921
  | `APP_ENV` | Определяет окружение, используется как ключ секции авторизации |
808
922
  | `APP_NAME` | Название приложения (используется в описании задач) |
809
923
  | `CONFIG_DIR` | Путь к директории с конфигами (по умолчанию `../config`) |
924
+ | `FETCH_TIMEOUT_MS` | Бюджет одной попытки `fetchRetry`/`fetchWithTimeout`, мс (по умолчанию `60000`; `0` — без таймаута) |
810
925
  | `CHATAPP_EMAIL` | Email аккаунта ChatApp |
811
926
  | `CHATAPP_PASS` | Пароль аккаунта ChatApp |
812
927
  | `CHATAPP_APP_ID` | ID приложения в ChatApp |
@@ -820,6 +935,7 @@ APP_B24_CLIENT_ID=xxx
820
935
  APP_B24_CLIENT_SECRET=yyy
821
936
  APP_NAME=MyApp
822
937
  CONFIG_DIR=../config
938
+ FETCH_TIMEOUT_MS=60000
823
939
 
824
940
  CHATAPP_EMAIL=your-email@example.com
825
941
  CHATAPP_PASS=your-password
package/bitrix24/b24.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  Handler,
12
12
  LogRecord,
13
13
  Formatter,
14
+ RestrictionParams,
14
15
  } from "@bitrix24/b24jssdk";
15
16
  import { logs } from "../logs/logs.ts";
16
17
  import { isNetworkError, isPreConnectionError } from "../utils/fetchRetry.ts";
@@ -114,14 +115,57 @@ const NETWORK_ERROR_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT", "ERR_NE
114
115
  * REST-методы B24, повтор которых создаёт дубликат сущности или запускает
115
116
  * повторное действие: add, create, start, send, uploadfile, import, register.
116
117
  *
117
- * SDK 2.x при ответе портала «200 без поля result» (заглушка прокси или WAF) падает
118
- * внутри собственной логирующей ветки и отдаёт ошибку со status 0 — неотличимую от
119
- * транспортного сбоя. Запрос при этом уже выполнен, поэтому retry создаст вторую
120
- * задачу, второй файл, второй экземпляр бизнес-процесса. Идемпотентные записи
121
- * (update, delete, set) повторять безопасно: повторное применение даёт то же состояние.
118
+ * Транспортная ошибка не говорит, дошёл ли запрос до портала: NETWORK_ERROR приходит
119
+ * и когда соединение не состоялось, и когда оно оборвалось после того, как портал уже
120
+ * принял и выполнил запрос. Во втором случае retry создаст вторую задачу, второй файл,
121
+ * второй экземпляр бизнес-процесса. Идемпотентные записи (update, delete, set) повторять
122
+ * безопасно: повторное применение даёт то же состояние.
123
+ *
124
+ * По той же причине у SDK отключены собственные повторы транспортных сбоев
125
+ * (SDK_RESTRICTION_PARAMS) — он этой разницы не знает вовсе.
122
126
  */
123
127
  const NON_IDEMPOTENT_METHOD_RE = /\.(add|create|start|send|uploadfile|import|register)(\.json)?(\?|$)/i;
124
128
 
129
+ /**
130
+ * Параметры ограничителя SDK. Передаются третьим аргументом конструктора B24OAuth
131
+ * и сливаются там с дефолтами `ParamsFactory.getDefault()`.
132
+ *
133
+ * Единый инвариант: **транспортные сбои SDK не повторяет вообще, повторяет только
134
+ * наш слой**, где работает гейт NON_IDEMPOTENT_METHOD_RE. У SDK нет понятия
135
+ * «создающий вызов»: оборванный crm.deal.add он повторил бы трижды и создал три сделки.
136
+ *
137
+ * Одного `retryOnNetworkError: false` для этого мало. Он добавляет в жёсткий список
138
+ * ровно два кода — NETWORK_ERROR и REQUEST_TIMEOUT, — а SDK конвертирует в них только
139
+ * `ERR_NETWORK` и `ECONNABORTED`. Остальные транспортные ошибки Node доезжают до
140
+ * лимитера под своим кодом (`ECONNRESET` при обрыве сокета, `ERR_BAD_RESPONSE` при
141
+ * 502/504 от шлюза), не находятся ни в жёстком, ни в мягком списке и потому считаются
142
+ * временными — то есть повторяются с backoff. Замерено на локальном сервере, рвущем
143
+ * соединение после приёма запроса: без hardErrorCodes портал выполняет crm.deal.add
144
+ * три раза, с ними — один. На SDK 2.0.0 был один: там транспорт маскировался в
145
+ * JSSDK_UNKNOWN_ERROR, а тот во встроенном жёстком списке.
146
+ *
147
+ * `ERR_NETWORK` и `ECONNABORTED` в списке не нужны: до лимитера они не доживают.
148
+ *
149
+ * Плата за список: `ECONNREFUSED`/`ENOTFOUND`/`EAI_AGAIN` доказывают, что запрос не ушёл,
150
+ * и SDK мог бы повторить их безопасно даже для создающих вызовов. Отказываемся сознательно —
151
+ * ровно так вёл себя 2.0.0, а один понятный инвариант дороже трёх сэкономленных попыток.
152
+ */
153
+ const SDK_RESTRICTION_PARAMS = {
154
+ retryOnNetworkError: false,
155
+ hardErrorCodes: [
156
+ "ECONNRESET",
157
+ "ECONNREFUSED",
158
+ "ENOTFOUND",
159
+ "ETIMEDOUT",
160
+ "EHOSTUNREACH",
161
+ "ENETUNREACH",
162
+ "EAI_AGAIN",
163
+ "EPIPE",
164
+ "EPROTO",
165
+ "ERR_BAD_RESPONSE",
166
+ ],
167
+ } as const satisfies RestrictionParams;
168
+
125
169
  const confAuthB24 = new Conf({
126
170
  cwd: path.resolve(CONFIG_DIR),
127
171
  configName: "authB24",
@@ -173,10 +217,12 @@ interface V2Envelope {
173
217
  * Читает сырой конверт ответа из AjaxResult.
174
218
  *
175
219
  * `getData()` отдаёт замороженную пару `{ result, time }` — полей `next` и `total`
176
- * в ней нет намеренно (в restApi:v3 их не существует), а `isMore()`/`getTotal()`/`getNext()`
177
- * помечены `@removed 2.0.0` и читают тот же приватный конверт. Единственный способ узнать
178
- * смещение следующей страницы, не опираясь на удаляемое API, — прочитать конверт напрямую:
179
- * `_data` объявлен `protected`, а не приватным полем класса, поэтому в рантайме доступен.
220
+ * в ней нет намеренно (в restApi:v3 их не существует). С версии 2.2.0 SDK снял
221
+ * `isMore()`/`getTotal()`/`getNext()` с удаления и объявил их постоянными читателями
222
+ * конверта restApi:v2, но числового смещения среди них так и нет: `isMore()` отвечает
223
+ * только «есть ли ещё», а `getNext(http)` сам делает следующий запрос, мимо нашего
224
+ * прогресса и retry. Поэтому конверт читаем напрямую: `_data` объявлен `protected`,
225
+ * а не приватным полем класса, поэтому в рантайме доступен.
180
226
  *
181
227
  * Это единственная точка связи с внутренностями SDK. Если она перестанет работать,
182
228
  * она обязана упасть громко: тихо оборванная на первой странице выборка — потеря данных.
@@ -238,11 +284,22 @@ class B24SdkLogHandler implements Handler {
238
284
  // нормальный путь через getResultData(): проверка существования в цикле
239
285
  // дала бы строку на итерацию. Такие сообщения — уровень debug.
240
286
  //
287
+ // 408 и 429 из правила исключены на будущее, сегодня эта ветка недостижима:
288
+ // status попадает в контекст записи уровня WARNING и выше ровно в одном месте
289
+ // SDK — #logNonRetryableClientError, а он вызывается из-под условия, которое
290
+ // 408 и 429 уже исключает. Сообщения лимитера про 429/503 идут другим путём,
291
+ // без status в контексте, и маппятся в warn независимо от этой строки.
292
+ // Условие оставлено потому, что фейлит в безопасную сторону: начни SDK класть
293
+ // status в такие записи — таймаут и упор в лимит будут видны, а не утонут в debug.
294
+ //
241
295
  // Остальное: уровень ERROR у SDK — это диагностика отдельной неуспешной
242
- // попытки, а таких попыток на один вызов до пятнадцати (3 внутри SDK × 5 наших).
296
+ // попытки. На транспортном сбое их пять — по числу наших попыток, повторы SDK
297
+ // выключены (см. SDK_RESTRICTION_PARAMS). На 429/503 и неизвестных 5xx SDK
298
+ // повторяет по-прежнему, там до пятнадцати: три внутри каждой из наших пяти.
243
299
  // Уровень error пакета уходит в чат B24, поэтому маппим SDK-ERROR в warn:
244
- // окончательный провал вызова логирует retry-слой, и он туда попадёт ровно один раз.
245
- const level = status >= 400 && status < 500 ? "debug" : record.level >= LogLevel.CRITICAL ? "error" : "warn";
300
+ // окончательный провал вызова логирует retry-слой, ровно один раз.
301
+ const isQuietClientError = status >= 400 && status < 500 && status !== 408 && status !== 429;
302
+ const level = isQuietClientError ? "debug" : record.level >= LogLevel.CRITICAL ? "error" : "warn";
246
303
 
247
304
  // Контекст SDK (requestId, method, code, wait, status) уже прогнан
248
305
  // через redactSensitiveParams — токены в него не попадают
@@ -303,7 +360,13 @@ function createB24Instance(): B24OAuth | null {
303
360
 
304
361
  const secret: B24OAuthSecret = { clientId: CLIENT_ID, clientSecret: CLIENT_SECRET };
305
362
 
306
- const b24 = new B24OAuth(authParams, secret);
363
+ // Параметры ограничителя задаём третьим аргументом конструктора: SDK сливает их
364
+ // с дефолтами (`{ ...ParamsFactory.getDefault(), ...restrictionParams }` в AbstractHttp)
365
+ // и передаёт обоим http-клиентам, v2 и v3, ещё до того как экземпляр можно использовать.
366
+ // Через setRestrictionManagerParams() было бы окно между созданием и настройкой,
367
+ // а результат вызова к тому же непроверяем: он завершается Promise.allSettled и
368
+ // не отклоняется никогда.
369
+ const b24 = new B24OAuth(authParams, secret, { restrictionParams: SDK_RESTRICTION_PARAMS });
307
370
 
308
371
  // Диагностика SDK от WARNING и выше уходит в logs пакета: предупреждения
309
372
  // callList/fetchList про игнорируемый order и остановку пагинации, сообщения лимитера.
@@ -491,9 +554,12 @@ export function stopProactiveRefresh(): void {
491
554
  /**
492
555
  * Проверяет, является ли ошибка из SDK сетевой.
493
556
  *
494
- * SDK 2.x маскирует транспортные сбои: в логирующей ветке падает собственный
495
- * TypeError, и наружу приходит AjaxError с кодом JSSDK_UNKNOWN_ERROR и status 0.
496
- * Поэтому проверяем и код, и originalError, и status.
557
+ * С версии 2.2.0 SDK отдаёт транспортный сбой честным кодом: NETWORK_ERROR (status 0)
558
+ * или REQUEST_TIMEOUT (status 408). До 2.2.0 он их маскировал — в логирующей ветке падал
559
+ * собственный TypeError, и наружу приходил AjaxError с кодом JSSDK_UNKNOWN_ERROR и status 0.
560
+ * Проверку по status 0 оставляем: она ловит всё, что SDK отдаёт без внятного кода.
561
+ * Сюда же попадает провал обновления токена внутри callMethod: SDK перезаворачивает
562
+ * SdkError в AjaxError с кодом JSSDK_UNKNOWN_ERROR и status 0, и исходный код теряется.
497
563
  * RefreshTokenError приходит как SdkError с кодом вроде ENOTFOUND и без originalError.
498
564
  */
499
565
  function isB24NetworkError(error: unknown): boolean {
@@ -511,6 +577,18 @@ function isB24NetworkError(error: unknown): boolean {
511
577
  if (originalError && isNetworkError(originalError)) return true;
512
578
  if (originalCode && NETWORK_ERROR_CODES.has(originalCode)) return true;
513
579
  if (status === 0) return true;
580
+
581
+ // 502 Bad Gateway: шлюз перед порталом не смог получить ответ от upstream —
582
+ // запрос почти наверняка не выполнялся, повтор осмыслен. Мы держим этот код
583
+ // в SDK_RESTRICTION_PARAMS.hardErrorCodes, чтобы SDK его не повторял, но
584
+ // повторить его должен наш слой: там создающие вызовы отсекает гейт.
585
+ //
586
+ // 504 сюда намеренно не входит, хотя приходит тем же кодом ERR_BAD_RESPONSE:
587
+ // это «портал взял запрос и считает прямо сейчас, шлюз устал ждать». Повтор
588
+ // создающего вызова дал бы дубликат, а читающего — ничего: за таймаут шлюза
589
+ // запрос не уложился один раз, не уложится и на второй, зато пять тяжёлых
590
+ // повторов добавят нагрузки порталу ровно тогда, когда ему и так плохо.
591
+ if (status === 502) return true;
514
592
  }
515
593
 
516
594
  return isNetworkError(error);
@@ -695,8 +773,10 @@ function createFacade(target: B24OAuth): Record<string, (...args: any[]) => any>
695
773
  const callMethod = async <T = any>(method: string, params?: object, start?: number): Promise<AjaxResult<T>> => {
696
774
  const merged: TypeCallParams = { ...params };
697
775
 
698
- // Явный params.start приоритетнее аргумента start — как в AbstractB24.callMethod
699
- if (!("start" in merged && Number.isInteger(merged.start)) && Number.isInteger(start)) {
776
+ // Явный params.start приоритетнее аргумента start — как в AbstractB24.callMethod.
777
+ // typeof, а не только Number.isInteger: последний ничего не сужает, и при
778
+ // exactOptionalPropertyTypes у потребителя присваивание number | undefined не проходит
779
+ if (!("start" in merged && Number.isInteger(merged.start)) && typeof start === "number" && Number.isInteger(start)) {
700
780
  merged.start = start;
701
781
  }
702
782
 
@@ -790,11 +870,14 @@ function createFacade(target: B24OAuth): Record<string, (...args: any[]) => any>
790
870
  };
791
871
 
792
872
  async function* fetchListMethod(method: string, params?: any, idKey?: string, customKeyForResult?: string | null): AsyncGenerator<any[]> {
873
+ // Ключи со значением undefined не подставляем вовсе: при exactOptionalPropertyTypes
874
+ // у потребителя явный undefined не подходит опциональному полю опций SDK.
875
+ // Поведение прежнее — null и undefined одинаково означают «ключ не передан»
793
876
  yield* v2().fetchList.make<any>({
794
877
  method,
795
878
  params,
796
- idKey,
797
- customKeyForResult: customKeyForResult === null ? undefined : customKeyForResult,
879
+ ...(idKey === undefined ? {} : { idKey }),
880
+ ...(customKeyForResult === null || customKeyForResult === undefined ? {} : { customKeyForResult }),
798
881
  });
799
882
  }
800
883