@andrey4emk/npm-app-back-b24 3.5.0 → 3.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -2
- package/bitrix24/b24.ts +134 -14
- package/bitrix24/eventB24.ts +37 -12
- package/package.json +1 -1
- package/sendMessage/smsgold.ts +5 -1
- package/utils/fetchRetry.ts +32 -2
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,17 @@ 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 **не** вызывают повторных попыток.
|
|
149
|
+
Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток.
|
|
150
|
+
|
|
151
|
+
**Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказ — на `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в чат B24 вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
|
|
152
|
+
|
|
153
|
+
**Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: при ответе портала «200 без поля `result`» (заглушка прокси или WAF) SDK падает в собственной ветке логирования и отдаёт ошибку со `status: 0`, неотличимую от транспортного сбоя — хотя запрос уже выполнен. Повтор в такой ситуации создавал до пяти дубликатов задачи или файла. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
|
|
154
|
+
|
|
155
|
+
Команды `callBatch` разбираются в обеих формах — кортеж `["crm.deal.add", {...}]` (основная в SDK 2.x) и объект `{ method, params }`. Если форму команды разобрать не удалось, вызов считается создающим и не повторяется: для защиты от дубликатов безопаснее ошибиться в сторону осторожности.
|
|
156
|
+
|
|
157
|
+
Важно: **у SDK есть собственный слой ретраев**, который гейт не отменяет. При настоящем разрыве соединения (`ECONNRESET` и подобные) SDK сходит на портал до 3 раз независимо от нашей защиты. Гейт закрывает конкретный случай — `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` и самим SDK не повторяется.
|
|
158
|
+
|
|
159
|
+
**Обмен refresh-токена** повторяется по более строгому правилу — только если соединение заведомо не состоялось (см. `isPreConnectionError()`). При таймауте или обрыве сервер мог уже провести ротацию; тогда локальный `refresh_token` мёртв независимо от наших действий, и повтор не помогает, а лишь маскирует проблему серией одинаковых отказов. Следующая попытка всё равно будет: проактивный таймер повторяет через 2 минуты при времени жизни токена около часа.
|
|
150
160
|
|
|
151
161
|
У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но он работает только при rate limit (HTTP 429, 503) и при распознанных `NETWORK_ERROR` / `REQUEST_TIMEOUT`. Замаскированные транспортные сбои приходят с кодом `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` — на таких ошибках SDK делает одну попытку, и повторяет только Proxy.
|
|
152
162
|
|
|
@@ -662,6 +672,20 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
662
672
|
// https://portal.bitrix24.ru/rest/1/***/crm.deal.get.json
|
|
663
673
|
```
|
|
664
674
|
|
|
675
|
+
- **`isPreConnectionError(error)`** — строгая проверка: соединение не состоялось, значит запрос **точно** не был выполнен сервером (`ENOTFOUND`, `ECONNREFUSED`, `EAI_AGAIN`, `ENETUNREACH`; код ищется и во вложенных `originalError` / `cause`). Отличие от `isNetworkError()` в том, что таймаут и обрыв соединения тоже сетевые, но при них ответ мог потеряться уже после обработки запроса. Используйте эту проверку там, где повтор небезопасен сам по себе: создание сущностей, отправка сообщений, обмен токена.
|
|
676
|
+
|
|
677
|
+
```js
|
|
678
|
+
import { isPreConnectionError } from "@andrey4emk/npm-app-back-b24";
|
|
679
|
+
|
|
680
|
+
try {
|
|
681
|
+
await sendSms(phone, text);
|
|
682
|
+
} catch (error) {
|
|
683
|
+
// Повторяем только если сообщение заведомо не ушло
|
|
684
|
+
if (isPreConnectionError(error)) await sendSms(phone, text);
|
|
685
|
+
else throw error;
|
|
686
|
+
}
|
|
687
|
+
```
|
|
688
|
+
|
|
665
689
|
- **`isNetworkError(error)`** — проверяет, является ли ошибка сетевой (стоит повторить запрос). Используется внутри `fetchRetry` и retry-обёртки `$b24`. Можно использовать в своём коде для аналогичных проверок.
|
|
666
690
|
|
|
667
691
|
```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,23 @@ 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
|
+
* повторное действие: add, create, start, send, uploadfile, import, register.
|
|
49
|
+
*
|
|
50
|
+
* SDK 2.x при ответе портала «200 без поля result» (заглушка прокси или WAF) падает
|
|
51
|
+
* внутри собственной логирующей ветки и отдаёт ошибку со status 0 — неотличимую от
|
|
52
|
+
* транспортного сбоя. Запрос при этом уже выполнен, поэтому retry создаст вторую
|
|
53
|
+
* задачу, второй файл, второй экземпляр бизнес-процесса. Идемпотентные записи
|
|
54
|
+
* (update, delete, set) повторять безопасно: повторное применение даёт то же состояние.
|
|
55
|
+
*/
|
|
56
|
+
const NON_IDEMPOTENT_METHOD_RE = /\.(add|create|start|send|uploadfile|import|register)(\.json)?(\?|$)/i;
|
|
45
57
|
|
|
46
58
|
const confAuthB24 = new Conf({
|
|
47
59
|
cwd: path.resolve(CONFIG_DIR),
|
|
@@ -230,11 +242,19 @@ async function refreshAuthWithMutex(): Promise<AuthData> {
|
|
|
230
242
|
return await $b24.auth.refreshAuth();
|
|
231
243
|
} catch (error) {
|
|
232
244
|
lastError = error;
|
|
233
|
-
|
|
245
|
+
|
|
246
|
+
// Повторяем только когда соединение заведомо не состоялось.
|
|
247
|
+
// При таймауте или обрыве сервер мог уже провести ротацию: тогда
|
|
248
|
+
// локальный refresh-токен мёртв независимо от наших действий, и повтор
|
|
249
|
+
// не помогает, а лишь маскирует проблему серией одинаковых отказов.
|
|
250
|
+
// Следующая попытка всё равно будет — проактивный таймер повторит
|
|
251
|
+
// через 2 минуты при времени жизни токена около часа
|
|
252
|
+
if (!isPreConnectionError(error) || attempt === RETRY_COUNT) throw error;
|
|
253
|
+
|
|
234
254
|
const msg = error instanceof Error ? error.message : String(error);
|
|
235
255
|
const code = error instanceof SdkError ? ` [${error.code}]` : "";
|
|
236
256
|
const delayMs = RETRY_DELAY_MS * attempt;
|
|
237
|
-
logs.add(`refreshAuth${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${delayMs}мс`, "
|
|
257
|
+
logs.add(`refreshAuth${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${delayMs}мс`, "warn");
|
|
238
258
|
await delay(delayMs);
|
|
239
259
|
}
|
|
240
260
|
}
|
|
@@ -321,19 +341,85 @@ function isB24NetworkError(error: unknown): boolean {
|
|
|
321
341
|
|
|
322
342
|
if (error instanceof SdkError) {
|
|
323
343
|
const { code, originalError, status } = error;
|
|
344
|
+
const originalCode = (originalError as { code?: string } | undefined)?.code;
|
|
324
345
|
|
|
325
|
-
if (
|
|
346
|
+
if (NETWORK_ERROR_CODES.has(code)) return true;
|
|
326
347
|
if (originalError && isNetworkError(originalError)) return true;
|
|
327
|
-
if (
|
|
348
|
+
if (originalCode && NETWORK_ERROR_CODES.has(originalCode)) return true;
|
|
328
349
|
if (status === 0) return true;
|
|
329
350
|
}
|
|
330
351
|
|
|
331
352
|
return isNetworkError(error);
|
|
332
353
|
}
|
|
333
354
|
|
|
355
|
+
/**
|
|
356
|
+
* Достаёт имя REST-метода из одной команды batch.
|
|
357
|
+
* Возвращает null, если форма команды не распознана.
|
|
358
|
+
*/
|
|
359
|
+
function extractBatchCommandMethod(cmd: unknown): string | null {
|
|
360
|
+
// Кортеж ["crm.deal.add", { ... }] — основная форма в SDK 2.x
|
|
361
|
+
if (Array.isArray(cmd)) {
|
|
362
|
+
return typeof cmd[0] === "string" ? cmd[0] : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Объект { method, params }
|
|
366
|
+
if (cmd && typeof cmd === "object") {
|
|
367
|
+
const method = (cmd as { method?: unknown }).method;
|
|
368
|
+
return typeof method === "string" ? method : null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Строка "crm.deal.add?ID=1" — в SDK 2.x эта форма уже не поддерживается
|
|
372
|
+
// (ParseRow бросает JSSDK_INTERACTION_BATCH_ROW_FAIL), разбираем на случай,
|
|
373
|
+
// если потребитель остался на ней со старой версии
|
|
374
|
+
if (typeof cmd === "string") {
|
|
375
|
+
return cmd;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Возвращает причину, по которой вызов запрещено повторять, либо null.
|
|
383
|
+
*
|
|
384
|
+
* Для callBatch действует правило fail-closed: если хоть одну команду разобрать
|
|
385
|
+
* не удалось, вызов считается создающим. Ошибиться в сторону лишней осторожности
|
|
386
|
+
* дешевле — потребитель получит ошибку вместо тихого дубликата.
|
|
387
|
+
*/
|
|
388
|
+
function getRetryBlockReason(sdkMethod: string, args: any[]): string | null {
|
|
389
|
+
const first = args[0];
|
|
390
|
+
|
|
391
|
+
// callMethod(method, params) и callListMethod(method, params, ...)
|
|
392
|
+
if (sdkMethod === "callMethod" || sdkMethod === "callListMethod") {
|
|
393
|
+
if (typeof first !== "string") return null;
|
|
394
|
+
const method = first.trim();
|
|
395
|
+
return NON_IDEMPOTENT_METHOD_RE.test(method) ? method : null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (sdkMethod !== "callBatch") return null;
|
|
399
|
+
|
|
400
|
+
// callBatch(calls, ...) — команды приходят массивом либо объектом-словарём
|
|
401
|
+
if (!first || typeof first !== "object") {
|
|
402
|
+
return "аргументы batch не разобраны";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const commands: unknown[] = Array.isArray(first) ? first : Object.values(first);
|
|
406
|
+
const blocking: string[] = [];
|
|
407
|
+
|
|
408
|
+
for (const cmd of commands) {
|
|
409
|
+
const method = extractBatchCommandMethod(cmd);
|
|
410
|
+
|
|
411
|
+
if (method === null) return "команда batch не разобрана";
|
|
412
|
+
if (NON_IDEMPOTENT_METHOD_RE.test(method.trim())) blocking.push(method.trim());
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return blocking.length > 0 ? blocking.join(", ") : null;
|
|
416
|
+
}
|
|
417
|
+
|
|
334
418
|
/** Оборачивает async-функцию retry-логикой при сетевых ошибках */
|
|
335
419
|
function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: any, methodName: string): T {
|
|
336
420
|
return (async (...args: any[]) => {
|
|
421
|
+
// Состав вызова между попытками не меняется — разбираем один раз
|
|
422
|
+
const blockReason = getRetryBlockReason(methodName, args);
|
|
337
423
|
let lastError: unknown;
|
|
338
424
|
|
|
339
425
|
for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
|
|
@@ -342,13 +428,33 @@ function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: a
|
|
|
342
428
|
} catch (error: unknown) {
|
|
343
429
|
lastError = error;
|
|
344
430
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
431
|
+
// Ошибка не сетевая (бизнес-логика B24, неверные параметры) — отдаём
|
|
432
|
+
// вызывающему коду как есть, он решает, что с ней делать
|
|
433
|
+
if (!isB24NetworkError(error)) throw error;
|
|
348
434
|
|
|
349
435
|
const msg = error instanceof Error ? error.message : String(error);
|
|
350
436
|
const code = error instanceof SdkError ? ` [${error.code}]` : "";
|
|
351
|
-
|
|
437
|
+
|
|
438
|
+
// Окончательные отказы логируем на error: собственные модули пакета
|
|
439
|
+
// (errorB24, Event, Smsgold) ошибку только возвращают вызывающему коду,
|
|
440
|
+
// но не пишут в лог — без этих строк сбой $b24 не виден нигде
|
|
441
|
+
|
|
442
|
+
if (attempt === RETRY_COUNT) {
|
|
443
|
+
logs.add(`$b24.${methodName}${code}: исчерпаны все ${RETRY_COUNT} попыток — ${msg}`, "error");
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Ошибка со status 0 может означать и «запрос не ушёл», и «запрос выполнен,
|
|
448
|
+
// а ответ не разобрался»: различить их нельзя, поэтому создающие вызовы
|
|
449
|
+
// не повторяем — лучше вернуть ошибку, чем создать дубликат
|
|
450
|
+
if (blockReason) {
|
|
451
|
+
logs.add(`$b24.${methodName}${code}: повтор отменён, вызов создаёт сущности (${blockReason}) — ${msg}`, "error");
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Промежуточные попытки — warn: в чат B24 уходит только уровень error,
|
|
456
|
+
// иначе один упавший вызов дал бы пять сообщений
|
|
457
|
+
logs.add(`$b24.${methodName}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс`, "warn");
|
|
352
458
|
|
|
353
459
|
await delay(RETRY_DELAY_MS);
|
|
354
460
|
}
|
|
@@ -435,5 +541,19 @@ export async function reinitializeB24(): Promise<SaveResult> {
|
|
|
435
541
|
|
|
436
542
|
if (_b24Raw) {
|
|
437
543
|
setupRefreshCallback(_b24Raw);
|
|
438
|
-
|
|
544
|
+
|
|
545
|
+
// Намеренно не ждём результат: импорт модуля не должен блокироваться сетевым
|
|
546
|
+
// запросом к oauth.bitrix.info. try/finally обязателен — если обновление всё же
|
|
547
|
+
// бросит (например, битый log.json уронит логгер), без него таймер не стартует
|
|
548
|
+
// никогда и процесс проживёт без обновления токена до перезапуска
|
|
549
|
+
void (async () => {
|
|
550
|
+
try {
|
|
551
|
+
await refreshAndSaveTokens();
|
|
552
|
+
} catch (error: unknown) {
|
|
553
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
554
|
+
logs.add(`Начальное обновление токенов упало: ${msg}`, "error");
|
|
555
|
+
} finally {
|
|
556
|
+
startProactiveRefresh();
|
|
557
|
+
}
|
|
558
|
+
})();
|
|
439
559
|
}
|
package/bitrix24/eventB24.ts
CHANGED
|
@@ -62,6 +62,21 @@ interface OfflineEventsResponse {
|
|
|
62
62
|
|
|
63
63
|
const SYSTEM_USER_ID = "138"; // ID системного пользователя для фильтрации
|
|
64
64
|
|
|
65
|
+
// ==================== Утилиты ====================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Приводит MESSAGES из события к массиву.
|
|
69
|
+
*
|
|
70
|
+
* B24 отдаёт коллекции то массивом, то словарём с числовыми ключами (так PHP
|
|
71
|
+
* сериализует разреженный массив), поэтому опираться на одну форму нельзя.
|
|
72
|
+
* Неизвестная форма даёт пустой массив, а не исключение.
|
|
73
|
+
*/
|
|
74
|
+
function normalizeMessages(messages: unknown): any[] {
|
|
75
|
+
if (Array.isArray(messages)) return messages;
|
|
76
|
+
if (messages && typeof messages === "object") return Object.values(messages);
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
65
80
|
// ==================== Класс ====================
|
|
66
81
|
|
|
67
82
|
/**
|
|
@@ -154,18 +169,28 @@ export class Event {
|
|
|
154
169
|
|
|
155
170
|
if (allEvents.length > 0) {
|
|
156
171
|
data.processId = arrOfflineEvents.process_id;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
172
|
+
// Событие может принести несколько сообщений. Раньше бралось только
|
|
173
|
+
// первое, а остальные терялись безвозвратно — событие после обработки
|
|
174
|
+
// удаляется из очереди B24.
|
|
175
|
+
//
|
|
176
|
+
// Разбор намеренно устойчив к форме и к битым элементам: исключение
|
|
177
|
+
// здесь означало бы, что get() вернёт ошибку, потребитель не вызовет
|
|
178
|
+
// clear(), и те же события придут следующим опросом — очередь встала бы
|
|
179
|
+
// навсегда на одном кривом сообщении
|
|
180
|
+
data.message = allEvents.flatMap((event) => {
|
|
181
|
+
const eventData = event.EVENT_DATA ?? {};
|
|
182
|
+
|
|
183
|
+
return normalizeMessages(eventData.MESSAGES)
|
|
184
|
+
.filter((messageData) => messageData?.chat && messageData?.message)
|
|
185
|
+
.map((messageData) => ({
|
|
186
|
+
connectorId: eventData.CONNECTOR,
|
|
187
|
+
lineId: eventData.LINE,
|
|
188
|
+
chatId: messageData.chat.id,
|
|
189
|
+
text: messageData.message.text || null,
|
|
190
|
+
file: this.convertB24FilesToTelegramFormat(messageData.message.files),
|
|
191
|
+
attachments: messageData.message.attachments || null,
|
|
192
|
+
im: messageData.im || null,
|
|
193
|
+
}));
|
|
169
194
|
});
|
|
170
195
|
}
|
|
171
196
|
|
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", "EAI_NODATA", "ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH", "ENETDOWN"];
|
|
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
|
*
|
|
@@ -50,8 +79,9 @@ export function maskUrl(url: string | URL | Request): string {
|
|
|
50
79
|
raw
|
|
51
80
|
// Секрет входящего вебхука: /rest/<id пользователя>/<секрет>/
|
|
52
81
|
.replace(/(\/rest\/\d+\/)[^/?#]+/gi, "$1***")
|
|
53
|
-
// Токены в query
|
|
54
|
-
|
|
82
|
+
// Токены и подписи в query-параметрах. signature и sessid нужны отдельно:
|
|
83
|
+
// ссылка на файл B24-диска несёт секрет именно в signature
|
|
84
|
+
.replace(/([?&](?:auth|access_token|refresh_token|token|signature|sessid)=)[^&#]+/gi, "$1***")
|
|
55
85
|
);
|
|
56
86
|
}
|
|
57
87
|
|