@andrey4emk/npm-app-back-b24 3.7.1 → 3.8.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.
@@ -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
  }