@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.
@@ -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
- const res = await fetch(url!, {
89
- method: "POST",
90
- headers: {
91
- "Content-Type": "application/json",
92
- Authorization: token!,
93
- },
94
- body: JSON.stringify({
95
- recipient: phone,
96
- body: message,
97
- }),
98
- });
99
- const data: any = await res.json();
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
- const res = await fetch(url!, {
174
- method: "POST",
175
- headers: {
176
- "Content-Type": "application/json",
177
- Authorization: token!,
178
- },
179
- body: JSON.stringify({
180
- recipient: phone,
181
- url: fileUrl,
182
- file_name: fileName,
183
- caption: message,
184
- b64_file: base64,
185
- }),
186
- });
187
- const data: any = await res.json();
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
- const res = await fetch(url!, {
240
- method: "GET",
241
- headers: {
242
- "Content-Type": "application/json",
243
- Authorization: token!,
244
- },
245
- });
246
- const data: any = await res.json();
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 fetch(url, {
271
- method: "GET",
272
- headers: {
273
- "Content-Type": "application/json",
274
- Authorization: token,
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 fetch(url, {
308
- method: "POST",
309
- headers: {
310
- "Content-Type": "application/json",
311
- Authorization: token,
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
- body: JSON.stringify({
314
- recipient: phone,
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
- return { error: true, message: `Ошибка при создании контакта: ${errMsg}`, data: null };
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 fetch(fileUrl);
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
  }
@@ -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
- if (!isNetworkError(error) || attempt === retries) {
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
- logs.add(
119
- `fetchRetry: попытка ${attempt}/${retries} не удалась (${lastError.message}), повтор через ${delay}мс — ${maskUrl(url)}`,
120
- "warn"
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
- throw lastError;
369
+ // Сюда попадаем только при retries <= 0: цикл не выполнился ни разу
370
+ throw lastError ?? new Error("fetchRetry: retries должно быть >= 1");
128
371
  }