@andrey4emk/npm-app-back-b24 3.7.0 → 3.8.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
@@ -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
 
@@ -44,6 +44,47 @@ import { fetchRetry, maskUrl } from "@andrey4emk/npm-app-back-b24/fetchRetry";
44
44
 
45
45
  Корневой импорт при этом остаётся безопасным: если `APP_B24_CLIENT_ID` и `APP_B24_CLIENT_SECRET` не заданы, пакет считает, что OAuth в проекте не используется, молча выставляет `$b24 = null` и пишет об этом только в `debug`.
46
46
 
47
+ ### Строгость типов
48
+
49
+ Пакет публикуется исходниками на TypeScript и компилируется **в программе потребителя** — значит его код проверяется настройками потребителя, а не нашими. Поэтому сам пакет собирается с `strict` и `noUncheckedIndexedAccess`: на настройках Nuxt (где оба флага включены) штатный `nuxt typecheck` проходит по коду пакета без обходных скриптов и без `skipLibCheck`-заплаток.
50
+
51
+ Практическое следствие: индексация массива внутри пакета всегда сопровождается проверкой на `undefined`. Если в своём проекте эти флаги включены — ошибок из `node_modules/@andrey4emk/npm-app-back-b24` быть не должно.
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
+
47
88
  ## Использование
48
89
 
49
90
  ```js
@@ -64,8 +105,11 @@ import {
64
105
  Wappi,
65
106
  logs,
66
107
  fetchRetry,
108
+ fetchWithTimeout,
67
109
  isNetworkError,
110
+ isTimeoutError,
68
111
  maskUrl,
112
+ FETCH_TIMEOUTS,
69
113
  } from "@andrey4emk/npm-app-back-b24";
70
114
 
71
115
  // $b24 — готовый экземпляр B24OAuth (или null, если токены не настроены)
@@ -113,8 +157,11 @@ await wappi.sendMessageWappi("whatsApp", { phone: "+1234567890", message: "Пр
113
157
  // Логирование
114
158
  logs.add("Сообщение", "info");
115
159
 
116
- // fetch с повторными попытками при сетевых ошибках
160
+ // fetch с повторными попытками при сетевых ошибках (бюджет попытки — 60с)
117
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);
118
165
  ```
119
166
 
120
167
  ## API
@@ -161,15 +208,21 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
161
208
 
162
209
  **Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказ — на `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в чат B24 вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
163
210
 
164
- **Создающие вызовы не повторяются 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`) повторяются как прежде: повторное применение даёт то же состояние.
165
212
 
166
213
  Команды `callBatch` разбираются в обеих формах — кортеж `["crm.deal.add", {...}]` (основная в SDK 2.x) и объект `{ method, params }`. Если форму команды разобрать не удалось, вызов считается создающим и не повторяется: для защиты от дубликатов безопаснее ошибиться в сторону осторожности.
167
214
 
168
- Важно: **у 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 вместе с риском дубликатов.
169
220
 
170
221
  **Обмен refresh-токена** повторяется по более строгому правилу — только если соединение заведомо не состоялось (см. `isPreConnectionError()`). При таймауте или обрыве сервер мог уже провести ротацию; тогда локальный `refresh_token` мёртв независимо от наших действий, и повтор не помогает, а лишь маскирует проблему серией одинаковых отказов. Следующая попытка всё равно будет: проактивный таймер повторяет через 2 минуты при времени жизни токена около часа.
171
222
 
172
- У самого 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** не повторяет никто — это «портал взял запрос и считает прямо сейчас»; повтор создающего вызова дал бы дубликат, а читающего — ничего, кроме нагрузки на портал в худший для него момент.
173
226
 
174
227
  **Диагностика SDK.** Начиная с 3.7.0 собственные сообщения SDK от уровня `WARNING` и выше попадают в лог пакета с префиксом `SDK B24:` вместе с контекстом (`requestId`, `method`, `status`, код ошибки) — например предупреждение `fetchList` про игнорируемый `order` или про остановку пагинации, когда `idKey` не совпал с полем ответа. Порог нужен: на уровне `info` SDK пишет две строки на каждый запрос. Ошибки SDK приходят как `warn`, а не `error` — это диагностика отдельной неуспешной попытки, а окончательный провал вызова один раз пишет retry-слой.
175
228
 
@@ -493,6 +546,12 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
493
546
  );
494
547
  ```
495
548
 
549
+ Конструктор сразу разрешает тип мессенджера для `whatsApp` и `telegram` — берёт `messenger[0].type` и держит в полях, вместо того чтобы индексировать массив при каждой отправке. Если конфига нет или список `messenger` пуст, тип остаётся неразрешённым, и **методы этого мессенджера возвращают** `{ error: true, message: "ChatApp: не разрешён тип мессенджера whatsApp (нет конфига или пуст messenger)", data: null }` — вместо запроса на URL с `undefined` в пути. Конструктор при этом **не бросает**: экземпляр обычно создаётся на уровне модуля, и бросок уронил бы загрузку всего модуля отправки вместе с соседними каналами.
550
+
551
+ **Таймаут отправки — статус неизвестен.** Запросы идут с бюджетом времени (10 секунд на работу с токеном, 20 на проверку номера, 30 на отправку сообщения, 60 на отправку файла). Если ответа не дождались, отправка **могла состояться** — методы возвращают `{ error: true, message: "ChatApp: таймаут ... — статус неизвестен", data: null }`, а не бросают исключение. Повторять такую отправку нельзя: клиент получит второе сообщение. Пакет дополнительно пишет строку уровня `error` (она уходит в чат B24) — решение о повторе принимает человек. Раньше зависание долетало до вызывающего кода исключением через пять минут; теперь оно возвращается объектом через тридцать секунд, поэтому запись в лог обязательна: без неё отказ стал бы тише, чем был.
552
+
553
+ Отказ локальный: пустой `whatsApp` не мешает `telegram`. Но конфиг всё равно нужен для **обоих** мессенджеров, даже если проект пользуется одним — иначе второй канал молча перестанет работать, и узнать об этом можно будет только по `res.error` в логе.
554
+
496
555
  **Методы:**
497
556
 
498
557
  - **`makeTokenChatApp()`** — получить токены доступа для ChatApp API.
@@ -554,6 +613,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
554
613
  - `messengerType` (string) — `"whatsApp"` или `"telegram"`.
555
614
  - `phone` (string) — номер телефона.
556
615
  - **Возвращает:** промис с объектом `{ error, message, data }`, где `data.check` — результат проверки.
616
+ - **Особенности:** если API ответил `success: true`, но без объекта `data`, метод возвращает `{ error: true, message: "Ответ ChatApp без поля data ...", data }` с сырым ответом. Раньше в этом случае наружу летел `TypeError` — метод не обёрнут в `try/catch`. Подставлять `check: undefined` было бы хуже: вызывающий принял бы непонятный ответ за «телефон не найден».
557
617
 
558
618
  ### Smsgold
559
619
 
@@ -564,10 +624,13 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
564
624
 
565
625
  const smsClient = new Smsgold(
566
626
  { user: "smsgold_login", pass: "smsgold_password" },
567
- b24Instance // опционально — экземпляр B24OAuth для загрузки файлов на диск
627
+ b24Instance, // опционально — экземпляр B24OAuth для загрузки файлов на диск
628
+ 123456 // опционально — ID папки на диске B24 для вложений, по умолчанию 2792881
568
629
  );
569
630
  ```
570
631
 
632
+ Третий параметр — папка, куда кладётся файл из `fileUrl` перед отправкой SMS. Дефолт `2792881` — папка «contract_company» портала `repacheb.bitrix24.ru`; на другом портале такого ID нет, и его нужно передать своим. Параметр нужен только при отправке файлов.
633
+
571
634
  **Методы:**
572
635
 
573
636
  - **`sendSms(messageData)`** — отправить SMS.
@@ -578,7 +641,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
578
641
  - `message` (string) — текст сообщения.
579
642
  - `fileUrl` (string) — опционально, ссылка на файл (будет загружен на диск Bitrix24 и публичная ссылка добавлена в SMS).
580
643
  - `fileName` (string) — опционально, имя файла.
581
- - **Возвращает:** промис с объектом `{ error, message, result }`.
644
+ - **Возвращает:** промис с объектом `{ error, message, result }`, а при таймауте — ещё и `unknownDelivery: true`.
582
645
 
583
646
  ```js
584
647
  const result = await smsClient.sendSms({
@@ -590,6 +653,19 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
590
653
  }
591
654
  ```
592
655
 
656
+ - **Таймаут и неизвестный статус доставки.** Отправка идёт без повторов с бюджетом 30 секунд. Если SMSGold не ответил, запрос уже ушёл — сообщение **могло быть доставлено**, и повторять отправку нельзя. Такой результат отличается от обычной ошибки: `unknownDelivery: true` и текст «SMSGold не ответил за 30с — статус отправки неизвестен». Дополнительно пакет пишет строку уровня `error` (она уходит в чат B24), потому что решение о повторе должен принимать человек, а не код. Это единственное место, где `Smsgold` пишет в лог сам.
657
+
658
+ ```js
659
+ const result = await smsClient.sendSms({ phone, message });
660
+ if (result.unknownDelivery) {
661
+ // Не отправлять повторно: возможен дубль у клиента. Ставим задачу на ручную проверку
662
+ } else if (result.error) {
663
+ // Обычная ошибка — отправка не состоялась, повтор безопасен
664
+ }
665
+ ```
666
+
667
+ - **Скачивание файла** по `fileUrl` идёт через `fetchRetry` с 3 попытками и бюджетом 60 секунд на попытку (раньше было 5 попыток без таймаута): пять попыток по минуте задерживали бы саму отправку SMS до пяти минут.
668
+
593
669
  ### Email
594
670
 
595
671
  - **`Email`** — класс для отправки email через SMTP Яндекса. Экземпляр нужно создать самостоятельно.
@@ -629,11 +705,16 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
629
705
  - **Особенности:**
630
706
  - SMTP-сервер: `smtp.yandex.ru:465` (SMTPS).
631
707
  - Копия письма автоматически отправляется на адрес отправителя.
708
+ - Транспорт — `nodemailer` `^9` (диапазон, а не точная версия: иначе патч безопасности нельзя было бы применить на стороне потребителя).
709
+ - Вложения передаются готовым `Buffer` — файл по `fileUrl` пакет скачивает сам. Поля `path` и `href` намеренно не поддерживаются: nodemailer не должен ходить по URL. Скачивание идёт через `fetchWithTimeout` с бюджетом 60 секунд.
710
+ - Таймауты SMTP-транспорта переопределены: соединение 15 с, приветствие 10 с, сокет 120 с. Дефолты nodemailer (2 мин / 30 с / 10 мин) слишком щедры — письмо на Яндекс уходит за секунды, а 10 минут молчания сокета блокируют вызывающую очередь.
632
711
 
633
712
  ### Wappi
634
713
 
635
714
  - **`Wappi`** — класс для отправки сообщений через Wappi API (WhatsApp, Telegram, Max). Экземпляр нужно создать самостоятельно.
636
715
 
716
+ **Таймаут отправки — статус неизвестен.** Как и у `ChatApp`: бюджет 20 секунд на проверку номера, 30 на сообщение, 60 на файл. При таймауте отправки метод возвращает `{ error: true, message: "Wappi: таймаут ... — статус неизвестен", data: null }` и пишет строку уровня `error` в чат B24 — сообщение могло уйти, повторять нельзя. Отдельно про Telegram: `phoneCheckWappi` при таймауте проверки контакта **не создаёт контакт**, а возвращает ошибку. Иначе не дождавшись ответа мы бы завели контакт на номер, который мог уже быть в адресной книге, и объявили номер проверенным без проверки.
717
+
637
718
  ```js
638
719
  import { Wappi } from "@andrey4emk/npm-app-back-b24";
639
720
 
@@ -708,9 +789,9 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
708
789
  - Конфигурация хранится в файле `log.json` (через `conf`).
709
790
  - Каждый уровень можно включить/выключить, изменить цвет.
710
791
 
711
- ### fetchRetry
792
+ ### fetchRetry и таймауты
712
793
 
713
- - **`fetchRetry(url, options?, retries?, delay?)`** — обёртка над `fetch` с повторными попытками при сетевых ошибках. HTTP-ошибки (4xx, 5xx) **не** вызывают повторных попыток — повторяются только сетевые сбои (TypeError, ECONNRESET, ETIMEDOUT и т.д.).
794
+ - **`fetchRetry(url, options?, retries?, delay?, timeoutMs?)`** — обёртка над `fetch` с повторными попытками при сетевых ошибках. HTTP-ошибки (4xx, 5xx) **не** вызывают повторных попыток — повторяются только сетевые сбои (TypeError, ECONNRESET, ETIMEDOUT и т.д.) и собственный таймаут запроса.
714
795
 
715
796
  - **`maskUrl(url)`** — маскирует секреты в адресе перед записью в лог. URL входящего вебхука B24 содержит секрет прямо в пути (`/rest/1/<секрет>/method.json`), а токен авторизации может приходить в query-параметрах (`auth`, `access_token`, `refresh_token`, `token`). Хост и имя метода сохраняются, тело секрета заменяется на `***`. Используется внутри `fetchRetry`; применяйте в своём коде везде, где логируете адреса запросов к B24.
716
797
 
@@ -757,6 +838,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
757
838
  | `options` | RequestInit | `undefined` | Параметры fetch (метод, заголовки, body и т.д.) |
758
839
  | `retries` | number | `5` | Количество попыток |
759
840
  | `delay` | number | `500` | Задержка между попытками (мс) |
841
+ | `timeoutMs` | number | `60000` (или `FETCH_TIMEOUT_MS`) | Бюджет на одну попытку; `0` — без таймаута |
760
842
 
761
843
  - **Возвращает:** `Promise<Response>` — ответ от fetch.
762
844
 
@@ -785,6 +867,46 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
785
867
  );
786
868
  ```
787
869
 
870
+ #### Таймауты
871
+
872
+ С версии 3.8.0 каждый запрос пакета имеет бюджет времени: зависшее соединение больше не держится до таймаутов undici (5 минут на попытку, а при медленной «капле» данных — бесконечно).
873
+
874
+ - **Бюджет считается на попытку, а не на весь вызов.** Каждой попытке `fetchRetry` выдаётся свой `AbortSignal.timeout(timeoutMs)`. Бюджет покрывает и чтение тела ответа: сигнал живёт до конца запроса, поэтому `res.text()` / `res.arrayBuffer()` могут упасть с `TimeoutError` уже **после** возврата из `fetchRetry`. Для крупных файлов передавайте больший `timeoutMs` или `0`.
875
+ - **Худший случай по времени** — `retries × (timeoutMs + delay)`. При дефолтах это 5 × (60 000 + 500) ≈ 5 минут.
876
+ - **Свой `options.signal` отключает наш дефолт.** Если вызывающий передал сигнал именно в `options`, а `timeoutMs` не задан, пакет своего таймаута не добавляет — чужой осознанный бюджет не обрезается. Явно переданный `timeoutMs` применяется всегда, тогда побеждает тот сигнал, который сработал раньше. Отмена по чужому сигналу **никогда не повторяется**; различаем её по объекту сигнала, а не по типу ошибки: причиной отмены может быть любой объект, включая `TypeError`.
877
+ - **Сигнал у объекта `Request` на дефолт не влияет.** Он участвует в отмене — сигналы склеиваются через `AbortSignal.any`, иначе `fetch(request, init)` затёр бы его, — но признаком «вызывающий сам управляет временем» не считается: у любого `Request` сигнал есть всегда, даже когда его никто не задавал. Учитывать его значило бы молча снять таймаут со всех вызовов такой формы. Нужен свой бюджет при вызове с `Request` — передавайте `timeoutMs` пятым аргументом.
878
+ - **Таймаут при чтении тела не повторяется и не логируется.** Бюджет действует до конца запроса, поэтому `res.text()` / `res.arrayBuffer()` после возврата из `fetchRetry` могут упасть с `TimeoutError` уже у вас. Функция об этом не знает: повторов там нет, записи в лог тоже. Читаете большое тело — задавайте `timeoutMs` с запасом на скачивание.
879
+ - **Переменная `FETCH_TIMEOUT_MS`** меняет дефолт без правки кода (читается один раз при загрузке модуля), `FETCH_TIMEOUT_MS=0` выключает таймаут. Это аварийный рычаг на случай, когда дефолт обрывает живой долгий запрос — отчёт Seatable, Google Sheets, МойСклад. Действует только на вызовы **без явного** `timeoutMs`: внутренние вызовы пакета (ChatApp, Wappi, SMS, скачивание файлов) передают свои значения из `FETCH_TIMEOUTS` и переменную игнорируют. Значение должно быть целым от 0 до 2 147 483 647 — непригодное отбрасывается с записью в лог, а не выключает защиту молча.
880
+ - **Ограничение при вызове с объектом `Request`:** тело такого запроса читается первой попыткой, и повтор упадёт с `body used already`. Существовало и до таймаутов; для повторяемых запросов передавайте строку URL и `options`.
881
+
882
+ - **`fetchWithTimeout(url, options?, timeoutMs?)`** — один запрос с бюджетом времени, **без повторов и без логирования**. Для неидемпотентных операций: отправка сообщения, создание сущности — там, где повтор мог бы продублировать действие, но защита от зависшего соединения нужна. Правила про чужой сигнал те же, что у `fetchRetry`. Внутри пакета через неё идут все отправки ChatApp, Wappi и SMSGold.
883
+
884
+ ```js
885
+ import { fetchWithTimeout, FETCH_TIMEOUTS } from "@andrey4emk/npm-app-back-b24/fetchRetry";
886
+
887
+ const res = await fetchWithTimeout(
888
+ "https://api.example.com/orders",
889
+ { method: "POST", body: JSON.stringify(order) },
890
+ FETCH_TIMEOUTS.send // 30 000 мс
891
+ );
892
+ ```
893
+
894
+ - **`FETCH_TIMEOUTS`** — именованные бюджеты, которыми пакет пользуется сам: `quick` 10 000 (токены), `api` 20 000 (короткое чтение), `send` 30 000 (отправка), `transfer` 60 000 (файлы). **`DEFAULT_FETCH_TIMEOUT_MS`** — действующий дефолт `fetchRetry` (60 000 либо значение `FETCH_TIMEOUT_MS`).
895
+
896
+ - **`isTimeoutError(error)` / `isAbortError(error)`** — различают «не дождались» и «отменили». Ошибка таймаута не оборачивается в свою: наружу идёт исходный `DOMException` с `name: "TimeoutError"` (отмена без причины — `"AbortError"`), проверки читают именно `name`.
897
+
898
+ ```js
899
+ import { fetchRetry, isTimeoutError, isAbortError } from "@andrey4emk/npm-app-back-b24/fetchRetry";
900
+
901
+ try {
902
+ await fetchRetry(url, { signal: ac.signal }, 3, 500, 20_000);
903
+ } catch (error) {
904
+ if (isTimeoutError(error)) logs.add("не дождались ответа", "warn");
905
+ else if (isAbortError(error)) logs.add("запрос отменили", "debug");
906
+ else throw error;
907
+ }
908
+ ```
909
+
788
910
  ## Переменные окружения
789
911
 
790
912
  | Переменная | Назначение |
@@ -795,6 +917,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
795
917
  | `APP_ENV` | Определяет окружение, используется как ключ секции авторизации |
796
918
  | `APP_NAME` | Название приложения (используется в описании задач) |
797
919
  | `CONFIG_DIR` | Путь к директории с конфигами (по умолчанию `../config`) |
920
+ | `FETCH_TIMEOUT_MS` | Бюджет одной попытки `fetchRetry`/`fetchWithTimeout`, мс (по умолчанию `60000`; `0` — без таймаута) |
798
921
  | `CHATAPP_EMAIL` | Email аккаунта ChatApp |
799
922
  | `CHATAPP_PASS` | Пароль аккаунта ChatApp |
800
923
  | `CHATAPP_APP_ID` | ID приложения в ChatApp |
@@ -808,6 +931,7 @@ APP_B24_CLIENT_ID=xxx
808
931
  APP_B24_CLIENT_SECRET=yyy
809
932
  APP_NAME=MyApp
810
933
  CONFIG_DIR=../config
934
+ FETCH_TIMEOUT_MS=60000
811
935
 
812
936
  CHATAPP_EMAIL=your-email@example.com
813
937
  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
 
package/logs/logs.ts CHANGED
@@ -30,6 +30,15 @@ const B24_CLIENT_ID = "6rf74zyu842h6op1z186b08k7aavqq7z";
30
30
  /** Лимит длины сообщения для B24 чата (безопасный, с запасом на BB-обёртку) */
31
31
  const B24_TEXT_LIMIT = 4000;
32
32
 
33
+ /**
34
+ * Бюджет времени на отправку error-лога в чат B24, мс.
35
+ *
36
+ * Своя константа, а не `FETCH_TIMEOUTS` из `utils/fetchRetry.ts`: тот модуль импортирует
37
+ * `logs` первой строкой, и обратный импорт дал бы цикл. Дублирование одного числа
38
+ * дешевле цикла зависимостей.
39
+ */
40
+ const B24_CHAT_TIMEOUT_MS = 5_000;
41
+
33
42
  // ==================== Настройки ====================
34
43
 
35
44
  const configDir: string = process.env.CONFIG_DIR || "../config";
@@ -149,7 +158,10 @@ class LogsAPI {
149
158
 
150
159
  /**
151
160
  * Отправляет error-сообщение в чат B24 через webhook (fire-and-forget)
152
- * Использует нативный fetch, а не fetchRetry — во избежание циклической зависимости
161
+ * Использует нативный fetch, а не fetchRetry/fetchWithTimeout — во избежание
162
+ * циклической зависимости, поэтому и таймаут выставляется здесь вручную через
163
+ * `AbortSignal.timeout`: без него зависшее соединение никого бы не разбудило,
164
+ * вызов идёт без await
153
165
  * Ошибки не пробрасываются, только console.error
154
166
  */
155
167
  private sendToB24Chat(message: string, jsonData?: unknown): void {
@@ -195,6 +207,7 @@ class LogsAPI {
195
207
  DIALOG_ID: this.b24ChatId,
196
208
  MESSAGE: text,
197
209
  }),
210
+ signal: AbortSignal.timeout(B24_CHAT_TIMEOUT_MS),
198
211
  })
199
212
  .then((response) => {
200
213
  if (!response.ok) {
@@ -202,6 +215,11 @@ class LogsAPI {
202
215
  }
203
216
  })
204
217
  .catch((error: unknown) => {
218
+ // Проверка локальная, без импорта isTimeoutError — тот модуль импортирует logs
219
+ if ((error as { name?: unknown })?.name === "TimeoutError") {
220
+ console.error(`Logs B24 Chat: не ответил за ${B24_CHAT_TIMEOUT_MS / 1000}с, сообщение не доставлено`);
221
+ return;
222
+ }
205
223
  const errMsg = error instanceof Error ? error.message : String(error);
206
224
  console.error(`Logs B24 Chat: ошибка отправки — ${errMsg}`);
207
225
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrey4emk/npm-app-back-b24",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "description": "Bitrix24 OAuth helpers for Node.js projects",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -18,6 +18,9 @@
18
18
  "url": "git+ssh://git@github.com/andrey4emk/npm_appBackB24.git"
19
19
  },
20
20
  "keywords": [],
21
+ "engines": {
22
+ "node": ">=20.3.0"
23
+ },
21
24
  "author": "andrey4emk",
22
25
  "license": "MIT",
23
26
  "bugs": {
@@ -37,14 +40,14 @@
37
40
  "utils/fetchRetry.ts"
38
41
  ],
39
42
  "dependencies": {
40
- "@bitrix24/b24jssdk": "2.0.0",
43
+ "@bitrix24/b24jssdk": "2.2.0",
41
44
  "@types/express": "5.0.6",
42
- "@types/luxon": "3.7.1",
43
- "@types/node": "25.0.10",
44
- "@types/nodemailer": "7.0.9",
45
- "conf": "15.0.2",
46
- "dotenv": "17.2.3",
47
- "luxon": "3.4.4",
48
- "nodemailer": "7.0.6"
45
+ "@types/luxon": "3.7.4",
46
+ "@types/node": "25.9.5",
47
+ "@types/nodemailer": "8.0.1",
48
+ "conf": "15.1.0",
49
+ "dotenv": "17.4.2",
50
+ "luxon": "3.7.2",
51
+ "nodemailer": "^9.0.5"
49
52
  }
50
53
  }