@andrey4emk/npm-app-back-b24 3.5.0 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/bitrix24/b24.ts +78 -11
- package/bitrix24/eventB24.ts +7 -4
- package/package.json +1 -1
- package/sendMessage/smsgold.ts +5 -1
- package/utils/fetchRetry.ts +29 -0
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ npm install @andrey4emk/npm-app-back-b24
|
|
|
32
32
|
| --------------------------------------------- | ----------------------------- | ---------------------- |
|
|
33
33
|
| `@andrey4emk/npm-app-back-b24` | всё | да |
|
|
34
34
|
| `@andrey4emk/npm-app-back-b24/logs` | `logs` | нет |
|
|
35
|
-
| `@andrey4emk/npm-app-back-b24/fetchRetry` | `fetchRetry`, `isNetworkError`, `maskUrl` | нет
|
|
35
|
+
| `@andrey4emk/npm-app-back-b24/fetchRetry` | `fetchRetry`, `isNetworkError`, `isPreConnectionError`, `maskUrl` | нет |
|
|
36
36
|
|
|
37
37
|
Корневой импорт — barrel: он тянет модуль OAuth, который при загрузке читает `authB24.json` и, если приложение авторизовано, запускает таймер проактивного обновления токена. Проектам, которые ходят в B24 по **входящему вебхуку** или берут из пакета только логгер и `fetchRetry`, удобнее импортировать по подпутям — тогда OAuth-модуль не загружается вообще.
|
|
38
38
|
|
|
@@ -146,7 +146,11 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
146
146
|
| Попыток | 5 |
|
|
147
147
|
| Задержка | 500 мс (линейно растущая для refreshAuth) |
|
|
148
148
|
|
|
149
|
-
Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток. Каждая неудачная попытка логируется через `logs.add()` с уровнем `
|
|
149
|
+
Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток. Каждая неудачная попытка логируется через `logs.add()` с уровнем `warn` — в сообщение попадает код ошибки SDK. Уровень `warn` выбран намеренно: `error` уходит в чат B24, и пять попыток одного упавшего вызова превращались в пять сообщений. Финальный провал приходит вызывающему коду исключением и логируется им один раз.
|
|
150
|
+
|
|
151
|
+
**Создающие вызовы не повторяются.** Если в вызове есть метод, создающий сущность (`*.add`, `*.uploadfile`, `*.import`, `*.register` — в том числе внутри `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: при ответе портала «200 без поля `result`» (заглушка прокси или WAF) SDK падает в собственной ветке логирования и отдаёт ошибку со `status: 0`, неотличимую от транспортного сбоя — хотя запрос уже выполнен. Повтор в такой ситуации создавал до пяти дубликатов задачи или файла. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
|
|
152
|
+
|
|
153
|
+
**Обмен refresh-токена** повторяется по более строгому правилу — только если соединение заведомо не состоялось (`ENOTFOUND`, `ECONNREFUSED`, `EAI_AGAIN`, `ENETUNREACH`, см. `isPreConnectionError()`). При таймауте или обрыве сервер мог уже провести ротацию, а ответ потеряться: повтор вернул бы `invalid_grant` и оставил в конфиге мёртвый `refresh_token`.
|
|
150
154
|
|
|
151
155
|
У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но он работает только при rate limit (HTTP 429, 503) и при распознанных `NETWORK_ERROR` / `REQUEST_TIMEOUT`. Замаскированные транспортные сбои приходят с кодом `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` — на таких ошибках SDK делает одну попытку, и повторяет только Proxy.
|
|
152
156
|
|
|
@@ -662,6 +666,20 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
662
666
|
// https://portal.bitrix24.ru/rest/1/***/crm.deal.get.json
|
|
663
667
|
```
|
|
664
668
|
|
|
669
|
+
- **`isPreConnectionError(error)`** — строгая проверка: соединение не состоялось, значит запрос **точно** не был выполнен сервером (`ENOTFOUND`, `ECONNREFUSED`, `EAI_AGAIN`, `ENETUNREACH`; код ищется и во вложенных `originalError` / `cause`). Отличие от `isNetworkError()` в том, что таймаут и обрыв соединения тоже сетевые, но при них ответ мог потеряться уже после обработки запроса. Используйте эту проверку там, где повтор небезопасен сам по себе: создание сущностей, отправка сообщений, обмен токена.
|
|
670
|
+
|
|
671
|
+
```js
|
|
672
|
+
import { isPreConnectionError } from "@andrey4emk/npm-app-back-b24";
|
|
673
|
+
|
|
674
|
+
try {
|
|
675
|
+
await sendSms(phone, text);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
// Повторяем только если сообщение заведомо не ушло
|
|
678
|
+
if (isPreConnectionError(error)) await sendSms(phone, text);
|
|
679
|
+
else throw error;
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
665
683
|
- **`isNetworkError(error)`** — проверяет, является ли ошибка сетевой (стоит повторить запрос). Используется внутри `fetchRetry` и retry-обёртки `$b24`. Можно использовать в своём коде для аналогичных проверок.
|
|
666
684
|
|
|
667
685
|
```js
|
package/bitrix24/b24.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { B24OAuth, AjaxError, SdkError, Logger } from "@bitrix24/b24jssdk";
|
|
2
2
|
import type { B24OAuthParams, B24OAuthSecret, AuthData, AjaxResult } from "@bitrix24/b24jssdk";
|
|
3
3
|
import { logs } from "../logs/logs.ts";
|
|
4
|
-
import { isNetworkError } from "../utils/fetchRetry.ts";
|
|
4
|
+
import { isNetworkError, isPreConnectionError } from "../utils/fetchRetry.ts";
|
|
5
5
|
import Conf from "conf";
|
|
6
6
|
import path from "path";
|
|
7
7
|
import type { Request, Response } from "express";
|
|
@@ -37,11 +37,22 @@ const RETRY_DELAY_MS = 500;
|
|
|
37
37
|
/** Методы B24OAuth, оборачиваемые retry-логикой (fetchListMethod — async-генератор, его оборачивать нельзя) */
|
|
38
38
|
const RETRYABLE_METHODS = new Set(["callMethod", "callListMethod", "callBatch"]);
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Коды транспортного сбоя. Один набор и для SdkError (AjaxError, RefreshTokenError),
|
|
42
|
+
* и для вложенного AxiosError — списки совпадали, держать их раздельно смысла нет.
|
|
43
|
+
*/
|
|
44
|
+
const NETWORK_ERROR_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT", "ERR_NETWORK", "ECONNABORTED"]);
|
|
42
45
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
46
|
+
/**
|
|
47
|
+
* REST-методы B24, повтор которых создаёт дубликат сущности.
|
|
48
|
+
*
|
|
49
|
+
* SDK 2.x при ответе портала «200 без поля result» (заглушка прокси или WAF) падает
|
|
50
|
+
* внутри собственной логирующей ветки и отдаёт ошибку со status 0 — неотличимую от
|
|
51
|
+
* транспортного сбоя. Запрос при этом уже выполнен, поэтому retry создаст вторую
|
|
52
|
+
* задачу, второй файл и т.д. Идемпотентные записи (update, delete, set) повторять
|
|
53
|
+
* безопасно: повторное применение даёт то же состояние.
|
|
54
|
+
*/
|
|
55
|
+
const NON_IDEMPOTENT_METHOD_RE = /\.(add|uploadfile|import|register)(\?|$)/i;
|
|
45
56
|
|
|
46
57
|
const confAuthB24 = new Conf({
|
|
47
58
|
cwd: path.resolve(CONFIG_DIR),
|
|
@@ -230,11 +241,17 @@ async function refreshAuthWithMutex(): Promise<AuthData> {
|
|
|
230
241
|
return await $b24.auth.refreshAuth();
|
|
231
242
|
} catch (error) {
|
|
232
243
|
lastError = error;
|
|
233
|
-
|
|
244
|
+
|
|
245
|
+
// Повторяем только когда соединение заведомо не состоялось: при таймауте
|
|
246
|
+
// или обрыве сервер мог уже провести ротацию, ответ потеряться, и повтор
|
|
247
|
+
// вернёт invalid_grant — refresh-токен окажется сожжён, потребуется
|
|
248
|
+
// ручная переавторизация
|
|
249
|
+
if (!isPreConnectionError(error) || attempt === RETRY_COUNT) throw error;
|
|
250
|
+
|
|
234
251
|
const msg = error instanceof Error ? error.message : String(error);
|
|
235
252
|
const code = error instanceof SdkError ? ` [${error.code}]` : "";
|
|
236
253
|
const delayMs = RETRY_DELAY_MS * attempt;
|
|
237
|
-
logs.add(`refreshAuth${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${delayMs}мс`, "
|
|
254
|
+
logs.add(`refreshAuth${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${delayMs}мс`, "warn");
|
|
238
255
|
await delay(delayMs);
|
|
239
256
|
}
|
|
240
257
|
}
|
|
@@ -321,19 +338,48 @@ function isB24NetworkError(error: unknown): boolean {
|
|
|
321
338
|
|
|
322
339
|
if (error instanceof SdkError) {
|
|
323
340
|
const { code, originalError, status } = error;
|
|
341
|
+
const originalCode = (originalError as { code?: string } | undefined)?.code;
|
|
324
342
|
|
|
325
|
-
if (
|
|
343
|
+
if (NETWORK_ERROR_CODES.has(code)) return true;
|
|
326
344
|
if (originalError && isNetworkError(originalError)) return true;
|
|
327
|
-
if (
|
|
345
|
+
if (originalCode && NETWORK_ERROR_CODES.has(originalCode)) return true;
|
|
328
346
|
if (status === 0) return true;
|
|
329
347
|
}
|
|
330
348
|
|
|
331
349
|
return isNetworkError(error);
|
|
332
350
|
}
|
|
333
351
|
|
|
352
|
+
/** Достаёт имена REST-методов B24 из аргументов вызова SDK */
|
|
353
|
+
function extractB24Methods(sdkMethod: string, args: any[]): string[] {
|
|
354
|
+
const first = args[0];
|
|
355
|
+
|
|
356
|
+
// callMethod(method, params) и callListMethod(method, params, ...)
|
|
357
|
+
if (sdkMethod === "callMethod" || sdkMethod === "callListMethod") {
|
|
358
|
+
return typeof first === "string" ? [first] : [];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// callBatch(calls, ...) — команды приходят массивом либо объектом-словарём
|
|
362
|
+
if (sdkMethod === "callBatch") {
|
|
363
|
+
const commands: unknown[] = Array.isArray(first) ? first : first && typeof first === "object" ? Object.values(first) : [];
|
|
364
|
+
|
|
365
|
+
return commands
|
|
366
|
+
.map((cmd) => (typeof cmd === "string" ? cmd : (cmd as { method?: unknown })?.method))
|
|
367
|
+
.filter((method): method is string => typeof method === "string");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Возвращает методы вызова, повтор которых создал бы дубликаты сущностей */
|
|
374
|
+
function getNonIdempotentMethods(sdkMethod: string, args: any[]): string[] {
|
|
375
|
+
return extractB24Methods(sdkMethod, args).filter((method) => NON_IDEMPOTENT_METHOD_RE.test(method));
|
|
376
|
+
}
|
|
377
|
+
|
|
334
378
|
/** Оборачивает async-функцию retry-логикой при сетевых ошибках */
|
|
335
379
|
function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: any, methodName: string): T {
|
|
336
380
|
return (async (...args: any[]) => {
|
|
381
|
+
// Состав вызова между попытками не меняется — разбираем один раз
|
|
382
|
+
const nonIdempotent = getNonIdempotentMethods(methodName, args);
|
|
337
383
|
let lastError: unknown;
|
|
338
384
|
|
|
339
385
|
for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
|
|
@@ -348,7 +394,21 @@ function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: a
|
|
|
348
394
|
|
|
349
395
|
const msg = error instanceof Error ? error.message : String(error);
|
|
350
396
|
const code = error instanceof SdkError ? ` [${error.code}]` : "";
|
|
351
|
-
|
|
397
|
+
|
|
398
|
+
// Ошибка со status 0 может означать и «запрос не ушёл», и «запрос выполнен,
|
|
399
|
+
// а ответ не разобрался»: различить их нельзя, поэтому создающие вызовы
|
|
400
|
+
// не повторяем — лучше вернуть ошибку, чем создать дубликат
|
|
401
|
+
if (nonIdempotent.length > 0) {
|
|
402
|
+
logs.add(
|
|
403
|
+
`$b24.${methodName}${code}: повтор отменён, вызов создаёт сущности (${nonIdempotent.join(", ")}) — ${msg}`,
|
|
404
|
+
"warn"
|
|
405
|
+
);
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Уровень warn: промежуточные попытки не должны улетать в чат B24,
|
|
410
|
+
// финальный провал прилетит вызывающему коду исключением
|
|
411
|
+
logs.add(`$b24.${methodName}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс`, "warn");
|
|
352
412
|
|
|
353
413
|
await delay(RETRY_DELAY_MS);
|
|
354
414
|
}
|
|
@@ -435,5 +495,12 @@ export async function reinitializeB24(): Promise<SaveResult> {
|
|
|
435
495
|
|
|
436
496
|
if (_b24Raw) {
|
|
437
497
|
setupRefreshCallback(_b24Raw);
|
|
438
|
-
|
|
498
|
+
|
|
499
|
+
// Намеренно не ждём результат: импорт модуля не должен блокироваться сетевым
|
|
500
|
+
// запросом к oauth.bitrix.info. refreshAndSaveTokens() исключений не бросает —
|
|
501
|
+
// ошибку возвращает в SaveResult и пишет в лог сам
|
|
502
|
+
void (async () => {
|
|
503
|
+
await refreshAndSaveTokens();
|
|
504
|
+
startProactiveRefresh();
|
|
505
|
+
})();
|
|
439
506
|
}
|
package/bitrix24/eventB24.ts
CHANGED
|
@@ -154,10 +154,13 @@ export class Event {
|
|
|
154
154
|
|
|
155
155
|
if (allEvents.length > 0) {
|
|
156
156
|
data.processId = arrOfflineEvents.process_id;
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
// Событие может принести несколько сообщений. Раньше бралось только
|
|
158
|
+
// первое, а остальные терялись безвозвратно — событие после обработки
|
|
159
|
+
// удаляется из очереди B24
|
|
160
|
+
data.message = allEvents.flatMap((event) => {
|
|
161
|
+
const messages = event.EVENT_DATA.MESSAGES ?? [];
|
|
159
162
|
|
|
160
|
-
return {
|
|
163
|
+
return messages.map((messageData: any) => ({
|
|
161
164
|
connectorId: event.EVENT_DATA.CONNECTOR,
|
|
162
165
|
lineId: event.EVENT_DATA.LINE,
|
|
163
166
|
chatId: messageData.chat.id,
|
|
@@ -165,7 +168,7 @@ export class Event {
|
|
|
165
168
|
file: this.convertB24FilesToTelegramFormat(messageData.message.files),
|
|
166
169
|
attachments: messageData.message.attachments || null,
|
|
167
170
|
im: messageData.im || null,
|
|
168
|
-
};
|
|
171
|
+
}));
|
|
169
172
|
});
|
|
170
173
|
}
|
|
171
174
|
|
package/package.json
CHANGED
package/sendMessage/smsgold.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { B24OAuth } from "@bitrix24/b24jssdk";
|
|
2
2
|
import { getResultData } from "../bitrix24/b24.ts";
|
|
3
|
+
import { fetchRetry } from "../utils/fetchRetry.ts";
|
|
3
4
|
|
|
4
5
|
// ==================== Типы ====================
|
|
5
6
|
|
|
@@ -150,7 +151,10 @@ export class Smsgold {
|
|
|
150
151
|
|
|
151
152
|
private async downloadFileToUrl(fileUrl: string): Promise<{ error: boolean; message?: string; buffer?: Buffer }> {
|
|
152
153
|
try {
|
|
153
|
-
|
|
154
|
+
// Скачивание файла идемпотентно — повтор при сетевом сбое безопасен.
|
|
155
|
+
// Отправка SMS (fetch выше по коду) намеренно остаётся без retry:
|
|
156
|
+
// при потерянном ответе повтор отправил бы сообщение второй раз
|
|
157
|
+
const response = await fetchRetry(fileUrl);
|
|
154
158
|
if (!response.ok) {
|
|
155
159
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
156
160
|
}
|
package/utils/fetchRetry.ts
CHANGED
|
@@ -32,6 +32,35 @@ export function isNetworkError(error: unknown): boolean {
|
|
|
32
32
|
return false;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Коды, доказывающие, что соединение не было установлено: DNS не разрешился либо
|
|
37
|
+
* хост отверг подключение. Запрос при таких ошибках гарантированно не дошёл до сервера.
|
|
38
|
+
*/
|
|
39
|
+
const PRE_CONNECTION_ERROR_CODES = ["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ENETUNREACH"];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Проверяет, что соединение не состоялось — запрос точно не был выполнен сервером.
|
|
43
|
+
*
|
|
44
|
+
* Отличается от `isNetworkError()` строгостью: таймаут и обрыв соединения тоже сетевые,
|
|
45
|
+
* но при них ответ мог потеряться уже после того, как сервер обработал запрос.
|
|
46
|
+
* Используйте эту проверку вместо `isNetworkError()` там, где повтор небезопасен сам
|
|
47
|
+
* по себе: обмен refresh-токена, создание сущностей, отправка сообщений.
|
|
48
|
+
*
|
|
49
|
+
* @param error — пойманная ошибка
|
|
50
|
+
* @returns true, если повторить запрос заведомо безопасно
|
|
51
|
+
*/
|
|
52
|
+
export function isPreConnectionError(error: unknown): boolean {
|
|
53
|
+
if (!error || typeof error !== "object") return false;
|
|
54
|
+
|
|
55
|
+
const codes: unknown[] = [
|
|
56
|
+
(error as { code?: unknown }).code,
|
|
57
|
+
(error as { originalError?: { code?: unknown } }).originalError?.code,
|
|
58
|
+
(error as { cause?: { code?: unknown } }).cause?.code,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
return codes.some((code) => typeof code === "string" && PRE_CONNECTION_ERROR_CODES.includes(code));
|
|
62
|
+
}
|
|
63
|
+
|
|
35
64
|
/**
|
|
36
65
|
* Маскирует секреты в URL перед записью в лог.
|
|
37
66
|
*
|