@andrey4emk/npm-app-back-b24 3.7.1 → 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
 
@@ -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
 
@@ -501,6 +548,8 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
501
548
 
502
549
  Конструктор сразу разрешает тип мессенджера для `whatsApp` и `telegram` — берёт `messenger[0].type` и держит в полях, вместо того чтобы индексировать массив при каждой отправке. Если конфига нет или список `messenger` пуст, тип остаётся неразрешённым, и **методы этого мессенджера возвращают** `{ error: true, message: "ChatApp: не разрешён тип мессенджера whatsApp (нет конфига или пуст messenger)", data: null }` — вместо запроса на URL с `undefined` в пути. Конструктор при этом **не бросает**: экземпляр обычно создаётся на уровне модуля, и бросок уронил бы загрузку всего модуля отправки вместе с соседними каналами.
503
550
 
551
+ **Таймаут отправки — статус неизвестен.** Запросы идут с бюджетом времени (10 секунд на работу с токеном, 20 на проверку номера, 30 на отправку сообщения, 60 на отправку файла). Если ответа не дождались, отправка **могла состояться** — методы возвращают `{ error: true, message: "ChatApp: таймаут ... — статус неизвестен", data: null }`, а не бросают исключение. Повторять такую отправку нельзя: клиент получит второе сообщение. Пакет дополнительно пишет строку уровня `error` (она уходит в чат B24) — решение о повторе принимает человек. Раньше зависание долетало до вызывающего кода исключением через пять минут; теперь оно возвращается объектом через тридцать секунд, поэтому запись в лог обязательна: без неё отказ стал бы тише, чем был.
552
+
504
553
  Отказ локальный: пустой `whatsApp` не мешает `telegram`. Но конфиг всё равно нужен для **обоих** мессенджеров, даже если проект пользуется одним — иначе второй канал молча перестанет работать, и узнать об этом можно будет только по `res.error` в логе.
505
554
 
506
555
  **Методы:**
@@ -564,6 +613,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
564
613
  - `messengerType` (string) — `"whatsApp"` или `"telegram"`.
565
614
  - `phone` (string) — номер телефона.
566
615
  - **Возвращает:** промис с объектом `{ error, message, data }`, где `data.check` — результат проверки.
616
+ - **Особенности:** если API ответил `success: true`, но без объекта `data`, метод возвращает `{ error: true, message: "Ответ ChatApp без поля data ...", data }` с сырым ответом. Раньше в этом случае наружу летел `TypeError` — метод не обёрнут в `try/catch`. Подставлять `check: undefined` было бы хуже: вызывающий принял бы непонятный ответ за «телефон не найден».
567
617
 
568
618
  ### Smsgold
569
619
 
@@ -574,10 +624,13 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
574
624
 
575
625
  const smsClient = new Smsgold(
576
626
  { user: "smsgold_login", pass: "smsgold_password" },
577
- b24Instance // опционально — экземпляр B24OAuth для загрузки файлов на диск
627
+ b24Instance, // опционально — экземпляр B24OAuth для загрузки файлов на диск
628
+ 123456 // опционально — ID папки на диске B24 для вложений, по умолчанию 2792881
578
629
  );
579
630
  ```
580
631
 
632
+ Третий параметр — папка, куда кладётся файл из `fileUrl` перед отправкой SMS. Дефолт `2792881` — папка «contract_company» портала `repacheb.bitrix24.ru`; на другом портале такого ID нет, и его нужно передать своим. Параметр нужен только при отправке файлов.
633
+
581
634
  **Методы:**
582
635
 
583
636
  - **`sendSms(messageData)`** — отправить SMS.
@@ -588,7 +641,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
588
641
  - `message` (string) — текст сообщения.
589
642
  - `fileUrl` (string) — опционально, ссылка на файл (будет загружен на диск Bitrix24 и публичная ссылка добавлена в SMS).
590
643
  - `fileName` (string) — опционально, имя файла.
591
- - **Возвращает:** промис с объектом `{ error, message, result }`.
644
+ - **Возвращает:** промис с объектом `{ error, message, result }`, а при таймауте — ещё и `unknownDelivery: true`.
592
645
 
593
646
  ```js
594
647
  const result = await smsClient.sendSms({
@@ -600,6 +653,19 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
600
653
  }
601
654
  ```
602
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
+
603
669
  ### Email
604
670
 
605
671
  - **`Email`** — класс для отправки email через SMTP Яндекса. Экземпляр нужно создать самостоятельно.
@@ -640,12 +706,15 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
640
706
  - SMTP-сервер: `smtp.yandex.ru:465` (SMTPS).
641
707
  - Копия письма автоматически отправляется на адрес отправителя.
642
708
  - Транспорт — `nodemailer` `^9` (диапазон, а не точная версия: иначе патч безопасности нельзя было бы применить на стороне потребителя).
643
- - Вложения передаются готовым `Buffer` — файл по `fileUrl` пакет скачивает сам. Поля `path` и `href` намеренно не поддерживаются: nodemailer не должен ходить по URL.
709
+ - Вложения передаются готовым `Buffer` — файл по `fileUrl` пакет скачивает сам. Поля `path` и `href` намеренно не поддерживаются: nodemailer не должен ходить по URL. Скачивание идёт через `fetchWithTimeout` с бюджетом 60 секунд.
710
+ - Таймауты SMTP-транспорта переопределены: соединение 15 с, приветствие 10 с, сокет 120 с. Дефолты nodemailer (2 мин / 30 с / 10 мин) слишком щедры — письмо на Яндекс уходит за секунды, а 10 минут молчания сокета блокируют вызывающую очередь.
644
711
 
645
712
  ### Wappi
646
713
 
647
714
  - **`Wappi`** — класс для отправки сообщений через Wappi API (WhatsApp, Telegram, Max). Экземпляр нужно создать самостоятельно.
648
715
 
716
+ **Таймаут отправки — статус неизвестен.** Как и у `ChatApp`: бюджет 20 секунд на проверку номера, 30 на сообщение, 60 на файл. При таймауте отправки метод возвращает `{ error: true, message: "Wappi: таймаут ... — статус неизвестен", data: null }` и пишет строку уровня `error` в чат B24 — сообщение могло уйти, повторять нельзя. Отдельно про Telegram: `phoneCheckWappi` при таймауте проверки контакта **не создаёт контакт**, а возвращает ошибку. Иначе не дождавшись ответа мы бы завели контакт на номер, который мог уже быть в адресной книге, и объявили номер проверенным без проверки.
717
+
649
718
  ```js
650
719
  import { Wappi } from "@andrey4emk/npm-app-back-b24";
651
720
 
@@ -720,9 +789,9 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
720
789
  - Конфигурация хранится в файле `log.json` (через `conf`).
721
790
  - Каждый уровень можно включить/выключить, изменить цвет.
722
791
 
723
- ### fetchRetry
792
+ ### fetchRetry и таймауты
724
793
 
725
- - **`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 и т.д.) и собственный таймаут запроса.
726
795
 
727
796
  - **`maskUrl(url)`** — маскирует секреты в адресе перед записью в лог. URL входящего вебхука B24 содержит секрет прямо в пути (`/rest/1/<секрет>/method.json`), а токен авторизации может приходить в query-параметрах (`auth`, `access_token`, `refresh_token`, `token`). Хост и имя метода сохраняются, тело секрета заменяется на `***`. Используется внутри `fetchRetry`; применяйте в своём коде везде, где логируете адреса запросов к B24.
728
797
 
@@ -769,6 +838,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
769
838
  | `options` | RequestInit | `undefined` | Параметры fetch (метод, заголовки, body и т.д.) |
770
839
  | `retries` | number | `5` | Количество попыток |
771
840
  | `delay` | number | `500` | Задержка между попытками (мс) |
841
+ | `timeoutMs` | number | `60000` (или `FETCH_TIMEOUT_MS`) | Бюджет на одну попытку; `0` — без таймаута |
772
842
 
773
843
  - **Возвращает:** `Promise<Response>` — ответ от fetch.
774
844
 
@@ -797,6 +867,46 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
797
867
  );
798
868
  ```
799
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
+
800
910
  ## Переменные окружения
801
911
 
802
912
  | Переменная | Назначение |
@@ -807,6 +917,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
807
917
  | `APP_ENV` | Определяет окружение, используется как ключ секции авторизации |
808
918
  | `APP_NAME` | Название приложения (используется в описании задач) |
809
919
  | `CONFIG_DIR` | Путь к директории с конфигами (по умолчанию `../config`) |
920
+ | `FETCH_TIMEOUT_MS` | Бюджет одной попытки `fetchRetry`/`fetchWithTimeout`, мс (по умолчанию `60000`; `0` — без таймаута) |
810
921
  | `CHATAPP_EMAIL` | Email аккаунта ChatApp |
811
922
  | `CHATAPP_PASS` | Пароль аккаунта ChatApp |
812
923
  | `CHATAPP_APP_ID` | ID приложения в ChatApp |
@@ -820,6 +931,7 @@ APP_B24_CLIENT_ID=xxx
820
931
  APP_B24_CLIENT_SECRET=yyy
821
932
  APP_NAME=MyApp
822
933
  CONFIG_DIR=../config
934
+ FETCH_TIMEOUT_MS=60000
823
935
 
824
936
  CHATAPP_EMAIL=your-email@example.com
825
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.1",
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,7 +40,7 @@
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
45
  "@types/luxon": "3.7.4",
43
46
  "@types/node": "25.9.5",