@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 +134 -10
- package/bitrix24/b24.ts +103 -20
- package/logs/logs.ts +19 -1
- package/package.json +12 -9
- package/sendMessage/chatApp.ts +218 -86
- package/sendMessage/email.ts +11 -2
- package/sendMessage/smsgold.ts +57 -12
- package/sendMessage/wappi.ts +144 -53
- package/utils/fetchRetry.ts +251 -8
package/sendMessage/wappi.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { fetchWithTimeout, isTimeoutError, FETCH_TIMEOUTS } from "../utils/fetchRetry.ts";
|
|
2
|
+
import { logs } from "../logs/logs.ts";
|
|
3
|
+
|
|
1
4
|
// ==================== Типы ====================
|
|
2
5
|
|
|
3
6
|
/** Параметры авторизации Wappi-мессенджера */
|
|
@@ -27,6 +30,11 @@ interface WappiResult {
|
|
|
27
30
|
error: boolean;
|
|
28
31
|
message: string;
|
|
29
32
|
data: any;
|
|
33
|
+
/**
|
|
34
|
+
* Ответа не дождались. Отличает «проверка сказала нет» от «проверки не было»:
|
|
35
|
+
* без этого признака вызывающий код принимает таймаут за отрицательный ответ.
|
|
36
|
+
*/
|
|
37
|
+
timeout?: boolean;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
// ==================== Класс ====================
|
|
@@ -85,18 +93,42 @@ export class Wappi {
|
|
|
85
93
|
url = `https://wappi.pro/maxapi/sync/message/send?${sendOpenLine}profile_id=${profile_id}`;
|
|
86
94
|
}
|
|
87
95
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
let data: any;
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetchWithTimeout(
|
|
99
|
+
url!,
|
|
100
|
+
{
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
Authorization: token!,
|
|
105
|
+
},
|
|
106
|
+
body: JSON.stringify({
|
|
107
|
+
recipient: phone,
|
|
108
|
+
body: message,
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
FETCH_TIMEOUTS.send
|
|
112
|
+
);
|
|
113
|
+
data = await res.json();
|
|
114
|
+
} catch (error: unknown) {
|
|
115
|
+
// Метод исторически без try/catch — перехватываем только таймаут, который сами
|
|
116
|
+
// и вводим, чтобы не появился новый путь исключений там, где его сегодня нет.
|
|
117
|
+
// Остальные ошибки летят наружу как раньше
|
|
118
|
+
if (!isTimeoutError(error)) throw error;
|
|
119
|
+
|
|
120
|
+
// Уровень error — единственный, который уходит в чат B24. До появления
|
|
121
|
+
// таймаута зависание долетало исключением до потребителя и там порождало
|
|
122
|
+
// задачу через errorB24(); перехватив ошибку здесь, мы обязаны сами оставить
|
|
123
|
+
// след, иначе правка сделала бы отказ отправки тише, чем он был
|
|
124
|
+
logs.add(`[${phone}] Wappi: таймаут ${FETCH_TIMEOUTS.send}мс при отправке сообщения через ${messangerType} — статус неизвестен, повторять нельзя`, "error");
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
error: true,
|
|
128
|
+
message: `Wappi: таймаут ${FETCH_TIMEOUTS.send}мс при отправке сообщения через ${messangerType} — статус неизвестен`,
|
|
129
|
+
data: null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
100
132
|
|
|
101
133
|
if (data.status !== "done") {
|
|
102
134
|
return { error: true, message: `Ошибка при отправке сообщения в ChatApp через ${messangerType}`, data };
|
|
@@ -170,21 +202,40 @@ export class Wappi {
|
|
|
170
202
|
url = `https://wappi.pro/maxapi/async/message/file/url/send?${sendOpenLine}profile_id=${profile_id}`;
|
|
171
203
|
}
|
|
172
204
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
205
|
+
let data: any;
|
|
206
|
+
try {
|
|
207
|
+
// В теле уходит base64 файла — бюджет transfer, а не send
|
|
208
|
+
const res = await fetchWithTimeout(
|
|
209
|
+
url!,
|
|
210
|
+
{
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: {
|
|
213
|
+
"Content-Type": "application/json",
|
|
214
|
+
Authorization: token!,
|
|
215
|
+
},
|
|
216
|
+
body: JSON.stringify({
|
|
217
|
+
recipient: phone,
|
|
218
|
+
url: fileUrl,
|
|
219
|
+
file_name: fileName,
|
|
220
|
+
caption: message,
|
|
221
|
+
b64_file: base64,
|
|
222
|
+
}),
|
|
223
|
+
},
|
|
224
|
+
FETCH_TIMEOUTS.transfer
|
|
225
|
+
);
|
|
226
|
+
data = await res.json();
|
|
227
|
+
} catch (error: unknown) {
|
|
228
|
+
// Перехватываем только таймаут, остальные ошибки ведут себя как раньше
|
|
229
|
+
if (!isTimeoutError(error)) throw error;
|
|
230
|
+
|
|
231
|
+
logs.add(`[${phone}] Wappi: таймаут ${FETCH_TIMEOUTS.transfer}мс при отправке файла через ${messangerType} — статус неизвестен, повторять нельзя`, "error");
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
error: true,
|
|
235
|
+
message: `Wappi: таймаут ${FETCH_TIMEOUTS.transfer}мс при отправке файла через ${messangerType} — статус неизвестен`,
|
|
236
|
+
data: null,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
188
239
|
|
|
189
240
|
if (data.status !== "done") {
|
|
190
241
|
return { error: true, message: `Ошибка при отправке файла в ChatApp через ${messangerType}`, data };
|
|
@@ -219,6 +270,14 @@ export class Wappi {
|
|
|
219
270
|
return { error: false, message: `Номер ${phone} проверен для мессенджера ${messangerType}`, data: contactCheck.data };
|
|
220
271
|
}
|
|
221
272
|
|
|
273
|
+
// Ответа не дождались — это не «контакта нет». Создавать контакт вслепую нельзя:
|
|
274
|
+
// он мог уже быть в адресной книге, а результат объявил бы номер проверенным,
|
|
275
|
+
// хотя проверки не было
|
|
276
|
+
if (contactCheck.timeout) {
|
|
277
|
+
logs.add(`[${phone}] Wappi: таймаут ${FETCH_TIMEOUTS.api}мс при проверке контакта в Telegram — контакт не создаём`, "warn");
|
|
278
|
+
return { error: true, message: `Wappi: таймаут ${FETCH_TIMEOUTS.api}мс при проверке контакта ${phone} в Telegram`, data: null };
|
|
279
|
+
}
|
|
280
|
+
|
|
222
281
|
// 3 Если не существует, то номера в телеге нет, пробуем создать
|
|
223
282
|
const addContactResult = await this.addContactTelegramWappi(phone);
|
|
224
283
|
|
|
@@ -236,14 +295,29 @@ export class Wappi {
|
|
|
236
295
|
url = `https://wappi.pro/maxapi/sync/contact/check?profile_id=${profile_id}&phone=${phone}`;
|
|
237
296
|
}
|
|
238
297
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
298
|
+
let data: any;
|
|
299
|
+
try {
|
|
300
|
+
const res = await fetchWithTimeout(
|
|
301
|
+
url!,
|
|
302
|
+
{
|
|
303
|
+
method: "GET",
|
|
304
|
+
headers: {
|
|
305
|
+
"Content-Type": "application/json",
|
|
306
|
+
Authorization: token!,
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
FETCH_TIMEOUTS.api
|
|
310
|
+
);
|
|
311
|
+
data = await res.json();
|
|
312
|
+
} catch (error: unknown) {
|
|
313
|
+
// Перехватываем только таймаут, остальные ошибки ведут себя как раньше
|
|
314
|
+
if (!isTimeoutError(error)) throw error;
|
|
315
|
+
|
|
316
|
+
// warn, а не error: проверка номера ничего не меняет, повторить её безопасно
|
|
317
|
+
logs.add(`[${phone}] Wappi: таймаут ${FETCH_TIMEOUTS.api}мс при проверке номера в ${messangerType}`, "warn");
|
|
318
|
+
|
|
319
|
+
return { error: true, message: `Wappi: таймаут ${FETCH_TIMEOUTS.api}мс при проверке номера ${phone}`, data: null };
|
|
320
|
+
}
|
|
247
321
|
|
|
248
322
|
if (data.status !== "done") {
|
|
249
323
|
return { error: true, message: `Ошибка проверки номера ${phone} ${messangerType}`, data };
|
|
@@ -267,13 +341,17 @@ export class Wappi {
|
|
|
267
341
|
const url = `https://wappi.pro/tapi/sync/contact/get?profile_id=${profile_id}&phone=${phone}`;
|
|
268
342
|
|
|
269
343
|
try {
|
|
270
|
-
const res = await
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
344
|
+
const res = await fetchWithTimeout(
|
|
345
|
+
url,
|
|
346
|
+
{
|
|
347
|
+
method: "GET",
|
|
348
|
+
headers: {
|
|
349
|
+
"Content-Type": "application/json",
|
|
350
|
+
Authorization: token,
|
|
351
|
+
},
|
|
275
352
|
},
|
|
276
|
-
|
|
353
|
+
FETCH_TIMEOUTS.api
|
|
354
|
+
);
|
|
277
355
|
|
|
278
356
|
const data: any = await res.json();
|
|
279
357
|
|
|
@@ -286,6 +364,13 @@ export class Wappi {
|
|
|
286
364
|
return { error: false, message: `Контакт ${phone} получен из Telegram`, data };
|
|
287
365
|
} catch (error: unknown) {
|
|
288
366
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
367
|
+
|
|
368
|
+
// Отмечаем таймаут отдельно: вызывающий phoneCheckWappi иначе примет
|
|
369
|
+
// «не дождались ответа» за «контакта нет» и полезет создавать контакт
|
|
370
|
+
if (isTimeoutError(error)) {
|
|
371
|
+
return { error: true, message: `Ошибка при запросе контакта: ${errMsg}`, data: null, timeout: true };
|
|
372
|
+
}
|
|
373
|
+
|
|
289
374
|
return { error: true, message: `Ошибка при запросе контакта: ${errMsg}`, data: null };
|
|
290
375
|
}
|
|
291
376
|
}
|
|
@@ -304,17 +389,21 @@ export class Wappi {
|
|
|
304
389
|
const url = `https://wappi.pro/tapi/sync/contact/add?profile_id=${profile_id}`;
|
|
305
390
|
|
|
306
391
|
try {
|
|
307
|
-
const res = await
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
392
|
+
const res = await fetchWithTimeout(
|
|
393
|
+
url,
|
|
394
|
+
{
|
|
395
|
+
method: "POST",
|
|
396
|
+
headers: {
|
|
397
|
+
"Content-Type": "application/json",
|
|
398
|
+
Authorization: token,
|
|
399
|
+
},
|
|
400
|
+
body: JSON.stringify({
|
|
401
|
+
recipient: phone,
|
|
402
|
+
name: contactName,
|
|
403
|
+
}),
|
|
312
404
|
},
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
name: contactName,
|
|
316
|
-
}),
|
|
317
|
-
});
|
|
405
|
+
FETCH_TIMEOUTS.send
|
|
406
|
+
);
|
|
318
407
|
|
|
319
408
|
const data: any = await res.json();
|
|
320
409
|
if (data.status !== "done") {
|
|
@@ -325,14 +414,16 @@ export class Wappi {
|
|
|
325
414
|
return { error: false, message: `Контакт ${phone} успешно создан в Telegram`, data };
|
|
326
415
|
} catch (error: unknown) {
|
|
327
416
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
328
|
-
|
|
417
|
+
// Создание контакта неидемпотентно: при таймауте ответ потерян, а контакт мог создаться
|
|
418
|
+
const unknownStatus = isTimeoutError(error) ? " — контакт мог быть создан" : "";
|
|
419
|
+
return { error: true, message: `Ошибка при создании контакта: ${errMsg}${unknownStatus}`, data: null };
|
|
329
420
|
}
|
|
330
421
|
}
|
|
331
422
|
|
|
332
423
|
/** Скачиваем файл по ссылке и конвертируем в base64 */
|
|
333
424
|
private async convertToBase64(fileUrl: string): Promise<{ error: boolean; message?: string; data?: string }> {
|
|
334
425
|
try {
|
|
335
|
-
const response = await
|
|
426
|
+
const response = await fetchWithTimeout(fileUrl, undefined, FETCH_TIMEOUTS.transfer);
|
|
336
427
|
if (!response.ok) {
|
|
337
428
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
338
429
|
}
|
package/utils/fetchRetry.ts
CHANGED
|
@@ -12,11 +12,112 @@ const NETWORK_ERROR_CODES = [
|
|
|
12
12
|
"UND_ERR_SOCKET",
|
|
13
13
|
];
|
|
14
14
|
|
|
15
|
+
// ==================== Таймауты ====================
|
|
16
|
+
|
|
17
|
+
/** Дефолтный бюджет на один запрос, мс. 0 — таймаут выключен */
|
|
18
|
+
const FALLBACK_TIMEOUT_MS = 60_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Потолок таймера Node. Выше `AbortSignal.timeout()` либо бросает `ERR_OUT_OF_RANGE`
|
|
22
|
+
* (свыше 2^32-1), либо переполняется и превращает бюджет в 1 мс (от 2^31 до 2^32-1) —
|
|
23
|
+
* то есть обрывает вообще все запросы. Проверка обязательна: `FETCH_TIMEOUT_MS`
|
|
24
|
+
* описан как аварийный рычаг, а типовое действие в аварии — «поставлю побольше».
|
|
25
|
+
*/
|
|
26
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
27
|
+
|
|
28
|
+
/** Годится ли значение как бюджет: целое от 0 до потолка таймера */
|
|
29
|
+
function isValidTimeout(value: number): boolean {
|
|
30
|
+
return Number.isInteger(value) && value >= 0 && value <= MAX_TIMEOUT_MS;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Читает бюджет запроса из переменной окружения `FETCH_TIMEOUT_MS`.
|
|
35
|
+
*
|
|
36
|
+
* `dotenv.config()` вызывается в `logs/logs.ts`, а этот модуль импортирует `logs`
|
|
37
|
+
* первой строкой — значит к моменту чтения `process.env` файл `.env` уже загружен.
|
|
38
|
+
*
|
|
39
|
+
* Значение принимается, только если это целое число >= 0 (0 означает «без таймаута»).
|
|
40
|
+
* Любой мусор в переменной не должен молча превращаться в `NaN` или в обрезанное
|
|
41
|
+
* `parseInt`-число, поэтому проверяем фактом и падаем на дефолт с записью в лог.
|
|
42
|
+
*/
|
|
43
|
+
function readTimeoutFromEnv(): number {
|
|
44
|
+
const raw = process.env.FETCH_TIMEOUT_MS;
|
|
45
|
+
if (raw === undefined || raw.trim() === "") return FALLBACK_TIMEOUT_MS;
|
|
46
|
+
|
|
47
|
+
const parsed = Number(raw);
|
|
48
|
+
if (!isValidTimeout(parsed)) {
|
|
49
|
+
logs.add(`FETCH_TIMEOUT_MS: значение '${raw}' непригодно (нужно целое от 0 до ${MAX_TIMEOUT_MS}) — берём ${FALLBACK_TIMEOUT_MS}мс`, "warn");
|
|
50
|
+
return FALLBACK_TIMEOUT_MS;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Бюджет одной попытки по умолчанию, мс. Читается один раз при загрузке модуля
|
|
58
|
+
* из `FETCH_TIMEOUT_MS`, иначе 60 000. Значение 0 полностью выключает таймаут —
|
|
59
|
+
* это аварийный рычаг для случая, когда дефолт обрывает живой долгий запрос.
|
|
60
|
+
*/
|
|
61
|
+
export const DEFAULT_FETCH_TIMEOUT_MS: number = readTimeoutFromEnv();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Именованные бюджеты для вызовов внутри пакета: смысл каждого запроса тут известен,
|
|
65
|
+
* поэтому дефолт 60с (рассчитанный на чужой неизвестный код) им избыточен.
|
|
66
|
+
*
|
|
67
|
+
* - `quick` — проверка, обновление и выпуск токена;
|
|
68
|
+
* - `api` — короткое чтение (проверка номера, список лицензий, получение контакта);
|
|
69
|
+
* - `send` — отправка текста, создание контакта, отправка SMS;
|
|
70
|
+
* - `transfer` — скачивание файла и отправка файла/вложения.
|
|
71
|
+
*/
|
|
72
|
+
export const FETCH_TIMEOUTS = { quick: 10_000, api: 20_000, send: 30_000, transfer: 60_000 } as const;
|
|
73
|
+
|
|
74
|
+
/** Читает `name` с произвольного значения ошибки, не полагаясь на её тип */
|
|
75
|
+
function getErrorName(error: unknown): string | null {
|
|
76
|
+
if (typeof error !== "object" || error === null) return null;
|
|
77
|
+
|
|
78
|
+
const name = (error as { name?: unknown }).name;
|
|
79
|
+
return typeof name === "string" ? name : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Проверяет, что запрос прерван по таймауту (`AbortSignal.timeout`).
|
|
84
|
+
*
|
|
85
|
+
* Прерывание по таймауту даёт `DOMException` с `name: "TimeoutError"`.
|
|
86
|
+
* Проверка идёт по `name`, а не по `instanceof DOMException`: у части потребителей
|
|
87
|
+
* в `lib` нет DOM, и `DOMException` как значение там не объявлен.
|
|
88
|
+
*
|
|
89
|
+
* @param error — пойманная ошибка
|
|
90
|
+
* @returns true, если ответа не дождались
|
|
91
|
+
*/
|
|
92
|
+
export function isTimeoutError(error: unknown): boolean {
|
|
93
|
+
return getErrorName(error) === "TimeoutError";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Проверяет, что запрос отменён по сигналу вызывающего кода (`AbortController.abort()`).
|
|
98
|
+
*
|
|
99
|
+
* Отмена без причины даёт `DOMException` с `name: "AbortError"`. Если отмена сделана
|
|
100
|
+
* с явной причиной (`abort(new Error(...))`), наружу приходит сам объект причины —
|
|
101
|
+
* такой случай этой проверкой не ловится, и ловиться не должен: тип причины выбирает
|
|
102
|
+
* тот, кто отменял.
|
|
103
|
+
*
|
|
104
|
+
* @param error — пойманная ошибка
|
|
105
|
+
* @returns true, если операцию отменили
|
|
106
|
+
*/
|
|
107
|
+
export function isAbortError(error: unknown): boolean {
|
|
108
|
+
return getErrorName(error) === "AbortError";
|
|
109
|
+
}
|
|
110
|
+
|
|
15
111
|
/**
|
|
16
112
|
* Проверяет, является ли ошибка сетевой (стоит повторить запрос).
|
|
17
113
|
* Экспортируется для использования в других retry-обёртках (например, для $b24).
|
|
18
114
|
*/
|
|
19
115
|
export function isNetworkError(error: unknown): boolean {
|
|
116
|
+
// Прерывание по сигналу сетевой ошибкой не считается: повторять решает тот,
|
|
117
|
+
// кто владеет сигналом. Проверка обязана стоять до ветки с TypeError —
|
|
118
|
+
// внешняя отмена может нести любую причину, включая TypeError
|
|
119
|
+
if (isTimeoutError(error) || isAbortError(error)) return false;
|
|
120
|
+
|
|
20
121
|
if (error instanceof TypeError) return true;
|
|
21
122
|
|
|
22
123
|
if (error instanceof Error) {
|
|
@@ -85,44 +186,186 @@ export function maskUrl(url: string | URL | Request): string {
|
|
|
85
186
|
);
|
|
86
187
|
}
|
|
87
188
|
|
|
189
|
+
// ==================== Работа с сигналами ====================
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Собирает сигналы отмены, которые пришли снаружи: из `options.signal` и из самого
|
|
193
|
+
* `Request`, если запрос передан объектом.
|
|
194
|
+
*
|
|
195
|
+
* Сигнал у `Request` учитывается обязательно: `fetch(request, init)` затирает
|
|
196
|
+
* собственный сигнал запроса тем, что лежит в `init`, — без этой склейки чужая
|
|
197
|
+
* отмена молча перестала бы работать.
|
|
198
|
+
*
|
|
199
|
+
* Но признаком «вызывающий сам управляет бюджетом» этот сигнал не является:
|
|
200
|
+
* у любого `Request` он есть всегда, даже пустой. Решение о дефолтном таймауте
|
|
201
|
+
* принимается только по `options.signal` — см. `resolveTimeoutMs()`.
|
|
202
|
+
*/
|
|
203
|
+
function collectExternalSignals(url: string | URL | Request, options?: RequestInit): AbortSignal[] {
|
|
204
|
+
const signals: AbortSignal[] = [];
|
|
205
|
+
|
|
206
|
+
if (options?.signal) signals.push(options.signal);
|
|
207
|
+
if (url instanceof Request && url.signal) signals.push(url.signal);
|
|
208
|
+
|
|
209
|
+
return signals;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Считает бюджет попытки.
|
|
214
|
+
*
|
|
215
|
+
* Явно переданный `timeoutMs` побеждает всегда (включая 0 — «без таймаута»), но
|
|
216
|
+
* непригодное значение отбрасываем: `AbortSignal.timeout()` бросает на дробном,
|
|
217
|
+
* отрицательном и NaN, а молча выключать защиту такое значение не должно.
|
|
218
|
+
*
|
|
219
|
+
* Если бюджет не задан, а вызывающий передал сигнал **в `options`**, свой дефолт не
|
|
220
|
+
* навязываем: он обрезал бы осознанно выставленный чужой таймаут. Сигнал самого
|
|
221
|
+
* `Request` таким признаком не является — он есть у любого `Request`, даже когда
|
|
222
|
+
* вызывающий его не задавал, и учёт этого сигнала здесь выключил бы таймаут
|
|
223
|
+
* для всех вызовов с объектом `Request`.
|
|
224
|
+
*/
|
|
225
|
+
function resolveTimeoutMs(explicit: number | undefined, hasOwnBudget: boolean): number {
|
|
226
|
+
if (explicit !== undefined) {
|
|
227
|
+
if (isValidTimeout(explicit)) return explicit;
|
|
228
|
+
|
|
229
|
+
logs.add(`fetchRetry: timeoutMs=${explicit} непригоден (нужно целое от 0 до ${MAX_TIMEOUT_MS}) — берём ${DEFAULT_FETCH_TIMEOUT_MS}мс`, "warn");
|
|
230
|
+
return DEFAULT_FETCH_TIMEOUT_MS;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return hasOwnBudget ? 0 : DEFAULT_FETCH_TIMEOUT_MS;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Собирает параметры вызова fetch со склеенными сигналами.
|
|
238
|
+
*
|
|
239
|
+
* Поле `signal` либо отсутствует вовсе, либо содержит конкретный объект: явный
|
|
240
|
+
* `undefined` не подходит полю `signal?: AbortSignal | null` при
|
|
241
|
+
* `exactOptionalPropertyTypes` у части потребителей.
|
|
242
|
+
*/
|
|
243
|
+
function buildInit(options: RequestInit | undefined, signals: AbortSignal[]): RequestInit | undefined {
|
|
244
|
+
if (signals.length === 0) return options;
|
|
245
|
+
|
|
246
|
+
const first = signals[0];
|
|
247
|
+
if (signals.length === 1 && first) {
|
|
248
|
+
return { ...(options ?? {}), signal: first };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { ...(options ?? {}), signal: AbortSignal.any(signals) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Одиночный fetch с бюджетом времени — без повторных попыток и без логирования.
|
|
256
|
+
*
|
|
257
|
+
* Для неидемпотентных операций (отправка сообщения, создание сущности): повтор
|
|
258
|
+
* там запрещён, а защита от зависшего соединения нужна.
|
|
259
|
+
*
|
|
260
|
+
* Ошибка пробрасывается как есть: таймаут — `DOMException` с `name: "TimeoutError"`
|
|
261
|
+
* (проверяется через `isTimeoutError()`), отмена по чужому сигналу — причина этой отмены.
|
|
262
|
+
*
|
|
263
|
+
* Бюджет продолжает действовать и после возврата из функции — во время чтения тела
|
|
264
|
+
* (`res.text()`, `res.arrayBuffer()`), потому что сигнал живёт до конца запроса.
|
|
265
|
+
*
|
|
266
|
+
* @param url — адрес запроса
|
|
267
|
+
* @param options — параметры fetch (метод, заголовки, body и т.д.)
|
|
268
|
+
* @param timeoutMs — бюджет запроса в мс; 0 — без таймаута; не передан — `DEFAULT_FETCH_TIMEOUT_MS`
|
|
269
|
+
* (а при наличии своего `options.signal` — без своего таймаута)
|
|
270
|
+
* @returns Response от fetch
|
|
271
|
+
*/
|
|
272
|
+
export async function fetchWithTimeout(url: string | URL | Request, options?: RequestInit, timeoutMs?: number): Promise<Response> {
|
|
273
|
+
const externalSignals = collectExternalSignals(url, options);
|
|
274
|
+
const effectiveTimeout = resolveTimeoutMs(timeoutMs, Boolean(options?.signal));
|
|
275
|
+
const ownSignal = effectiveTimeout > 0 ? AbortSignal.timeout(effectiveTimeout) : null;
|
|
276
|
+
|
|
277
|
+
return await fetch(url, buildInit(options, ownSignal ? [...externalSignals, ownSignal] : externalSignals));
|
|
278
|
+
}
|
|
279
|
+
|
|
88
280
|
/**
|
|
89
281
|
* Обёртка над fetch с повторными попытками при сетевых ошибках.
|
|
90
282
|
* Повторяет запрос только при проблемах с сетью (TypeError, ECONNRESET и т.д.),
|
|
91
283
|
* HTTP-ошибки (4xx, 5xx) НЕ вызывают повторных попыток.
|
|
92
284
|
*
|
|
285
|
+
* Таймаут:
|
|
286
|
+
* - бюджет `timeoutMs` даётся **каждой попытке** заново, свой таймаут повторяется
|
|
287
|
+
* наравне с прочими сетевыми ошибками (`ETIMEDOUT` уже в этом списке);
|
|
288
|
+
* - худший случай по времени — `retries × (timeoutMs + delay)`;
|
|
289
|
+
* - бюджет действует и после возврата из функции: если тело ответа читается дольше
|
|
290
|
+
* бюджета, `res.text()` / `res.arrayBuffer()` упадут тем же `TimeoutError`;
|
|
291
|
+
* - если вызывающий передал свой `options.signal` (или `signal` у `Request`), а `timeoutMs`
|
|
292
|
+
* не задан, свой дефолт НЕ добавляется — чужой осознанный таймаут не обрезается;
|
|
293
|
+
* - отмена по чужому сигналу никогда не повторяется, ошибка пробрасывается как есть.
|
|
294
|
+
*
|
|
295
|
+
* Тип ошибки таймаута не оборачивается намеренно: потребители разбирают её по
|
|
296
|
+
* `name === "TimeoutError"` / `"AbortError"`. Понятный текст уходит в лог, а не в тип ошибки.
|
|
297
|
+
*
|
|
93
298
|
* @param url — адрес запроса
|
|
94
299
|
* @param options — параметры fetch (метод, заголовки, body и т.д.)
|
|
95
300
|
* @param retries — количество попыток (по умолчанию 5)
|
|
96
301
|
* @param delay — задержка между попытками в мс (по умолчанию 500)
|
|
302
|
+
* @param timeoutMs — бюджет одной попытки в мс; 0 — без таймаута; не передан —
|
|
303
|
+
* `DEFAULT_FETCH_TIMEOUT_MS` либо 0 при наличии чужого сигнала
|
|
97
304
|
* @returns Response от fetch
|
|
98
305
|
*/
|
|
99
306
|
export async function fetchRetry(
|
|
100
307
|
url: string | URL | Request,
|
|
101
308
|
options?: RequestInit,
|
|
102
309
|
retries: number = 5,
|
|
103
|
-
delay: number = 500
|
|
310
|
+
delay: number = 500,
|
|
311
|
+
timeoutMs?: number
|
|
104
312
|
): Promise<Response> {
|
|
105
313
|
let lastError: Error | null = null;
|
|
106
314
|
|
|
315
|
+
const externalSignals = collectExternalSignals(url, options);
|
|
316
|
+
const effectiveTimeout = resolveTimeoutMs(timeoutMs, Boolean(options?.signal));
|
|
317
|
+
|
|
107
318
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
319
|
+
// Свой сигнал на каждую попытку: сигнал одноразовый, и второй попытке
|
|
320
|
+
// достался бы уже сгоревший
|
|
321
|
+
const ownSignal = effectiveTimeout > 0 ? AbortSignal.timeout(effectiveTimeout) : null;
|
|
322
|
+
|
|
108
323
|
try {
|
|
109
|
-
const response = await fetch(url, options);
|
|
324
|
+
const response = await fetch(url, buildInit(options, ownSignal ? [...externalSignals, ownSignal] : externalSignals));
|
|
110
325
|
return response;
|
|
111
326
|
} catch (error: unknown) {
|
|
327
|
+
// DOMException наследует Error, поэтому TimeoutError доходит до вызывающего
|
|
328
|
+
// в исходном виде — оборачивать его в «понятную» ошибку нельзя, на name
|
|
329
|
+
// завязан разбор у потребителей
|
|
112
330
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
113
331
|
|
|
114
|
-
|
|
332
|
+
// Отмена пришла снаружи — повтор был бы прямым нарушением решения вызывающего.
|
|
333
|
+
// Различаем по объекту сигнала, а не по типу ошибки: причиной отмены может
|
|
334
|
+
// оказаться любой объект, включая TypeError, который isNetworkError() считает сетевым.
|
|
335
|
+
// Бросаем lastError, а не error: DOMException наследует Error и доходит нетронутым,
|
|
336
|
+
// а произвольная причина (abort("строка")) приводится к Error — контракт функции
|
|
337
|
+
// «наружу летит только Error» держится с самой первой версии
|
|
338
|
+
if (externalSignals.some((signal) => signal.aborted)) throw lastError;
|
|
339
|
+
|
|
340
|
+
const isOwnTimeout = ownSignal?.aborted === true;
|
|
341
|
+
|
|
342
|
+
if (!isOwnTimeout && !isNetworkError(error)) {
|
|
115
343
|
throw lastError;
|
|
116
344
|
}
|
|
117
345
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
346
|
+
if (attempt === retries) {
|
|
347
|
+
if (isOwnTimeout) {
|
|
348
|
+
logs.add(`fetchRetry: таймаут ${effectiveTimeout}мс на каждой из попыток (${retries}) — ${maskUrl(url)}`, "warn");
|
|
349
|
+
}
|
|
350
|
+
throw lastError;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (isOwnTimeout) {
|
|
354
|
+
logs.add(
|
|
355
|
+
`fetchRetry: попытка ${attempt}/${retries} прервана по таймауту ${effectiveTimeout}мс, повтор через ${delay}мс — ${maskUrl(url)}`,
|
|
356
|
+
"warn"
|
|
357
|
+
);
|
|
358
|
+
} else {
|
|
359
|
+
logs.add(
|
|
360
|
+
`fetchRetry: попытка ${attempt}/${retries} не удалась (${lastError.message}), повтор через ${delay}мс — ${maskUrl(url)}`,
|
|
361
|
+
"warn"
|
|
362
|
+
);
|
|
363
|
+
}
|
|
122
364
|
|
|
123
365
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
124
366
|
}
|
|
125
367
|
}
|
|
126
368
|
|
|
127
|
-
|
|
369
|
+
// Сюда попадаем только при retries <= 0: цикл не выполнился ни разу
|
|
370
|
+
throw lastError ?? new Error("fetchRetry: retries должно быть >= 1");
|
|
128
371
|
}
|