itd-api 0.10.3 → 0.11.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.
Files changed (79) hide show
  1. package/README.md +2 -1
  2. package/dist/events/index.cjs +27 -25
  3. package/dist/events/index.d.cts +3 -3
  4. package/dist/events/index.d.ts +3 -3
  5. package/dist/events/index.js +5 -5
  6. package/dist/index.cjs +136 -116
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.d.cts +18 -7
  9. package/dist/index.d.ts +18 -7
  10. package/dist/index.js +46 -30
  11. package/dist/index.js.map +1 -1
  12. package/dist/node/index.cjs +13 -13
  13. package/dist/node/index.cjs.map +1 -1
  14. package/dist/node/index.js +5 -5
  15. package/dist/rest/index.cjs +43 -40
  16. package/dist/rest/index.cjs.map +1 -1
  17. package/dist/rest/index.d.cts +3 -3
  18. package/dist/rest/index.d.ts +3 -3
  19. package/dist/rest/index.js +6 -6
  20. package/dist/shared/{events-BVfqWyBw.cjs → events-C6tmj8aW.cjs} +63 -59
  21. package/dist/shared/events-C6tmj8aW.cjs.map +1 -0
  22. package/dist/shared/{events-Cp7ITueA.d.ts → events-CAb9G7Sf.d.ts} +2 -2
  23. package/dist/shared/{events-Cwj7UPAa.js → events-MWNcnfrq.js} +19 -15
  24. package/dist/shared/events-MWNcnfrq.js.map +1 -0
  25. package/dist/shared/{events-D0Wtt-If.d.cts → events-Sc75x6rT.d.cts} +2 -2
  26. package/dist/shared/{key-value-store-B5GYVEYZ.cjs → key-value-store-DYSPsqwq.cjs} +11 -14
  27. package/dist/shared/key-value-store-DYSPsqwq.cjs.map +1 -0
  28. package/dist/shared/{key-value-store-Bt2PSMxY.js → key-value-store-w1Dn5Pjb.js} +5 -8
  29. package/dist/shared/key-value-store-w1Dn5Pjb.js.map +1 -0
  30. package/dist/shared/{multi-storage-C3Ngtop1.js → multi-storage-BLBrGN9Q.js} +6 -6
  31. package/dist/shared/multi-storage-BLBrGN9Q.js.map +1 -0
  32. package/dist/shared/{multi-storage-s-fPtjoy.cjs → multi-storage-C7-Gz6Z7.cjs} +8 -8
  33. package/dist/shared/multi-storage-C7-Gz6Z7.cjs.map +1 -0
  34. package/dist/shared/{options-DjEtbVaf.cjs → options-5tywTYId.cjs} +13 -13
  35. package/dist/shared/{options-DjEtbVaf.cjs.map → options-5tywTYId.cjs.map} +1 -1
  36. package/dist/shared/{options-i4LrKfqx.js → options-BerKicCs.js} +3 -3
  37. package/dist/shared/{options-i4LrKfqx.js.map → options-BerKicCs.js.map} +1 -1
  38. package/dist/shared/{redact-DJV0XM3n.js → redact-C5oxIvcc.js} +79 -18
  39. package/dist/shared/redact-C5oxIvcc.js.map +1 -0
  40. package/dist/shared/{redact-lAmck1Z6.cjs → redact-DoC7lSsp.cjs} +109 -24
  41. package/dist/shared/redact-DoC7lSsp.cjs.map +1 -0
  42. package/dist/shared/{render-DRmuHoEy.d.ts → render-CBroJPcV.d.ts} +178 -252
  43. package/dist/shared/{render-CJO_L2CD.d.cts → render-DM7AhTZW.d.cts} +178 -252
  44. package/dist/shared/{render-BT74nC1l.js → render-DRh5_aRe.js} +128 -135
  45. package/dist/shared/render-DRh5_aRe.js.map +1 -0
  46. package/dist/shared/{render-CYJSDypQ.cjs → render-Dnc84B4M.cjs} +234 -241
  47. package/dist/shared/render-Dnc84B4M.cjs.map +1 -0
  48. package/dist/shared/{storage-DiDe0PZM.js → storage-86wiHeXD.js} +3 -3
  49. package/dist/shared/{storage-DiDe0PZM.js.map → storage-86wiHeXD.js.map} +1 -1
  50. package/dist/shared/{storage-CZJZoaz9.cjs → storage-CIjejZDt.cjs} +4 -4
  51. package/dist/shared/{storage-CZJZoaz9.cjs.map → storage-CIjejZDt.cjs.map} +1 -1
  52. package/dist/shared/{url-BLnX-LYt.d.cts → url-BOi-_mJT.d.cts} +143 -79
  53. package/dist/shared/{url-BLnX-LYt.d.ts → url-BOi-_mJT.d.ts} +143 -79
  54. package/dist/shared/{url-DS0-lrl-.js → url-Bg61sk_Y.js} +741 -598
  55. package/dist/shared/url-Bg61sk_Y.js.map +1 -0
  56. package/dist/shared/{url-BnO1aWsd.cjs → url-DTgkCWv5.cjs} +848 -717
  57. package/dist/shared/url-DTgkCWv5.cjs.map +1 -0
  58. package/dist/shared/{errors-BmP3TKoW.cjs → validate-CFx2dISs.cjs} +84 -5
  59. package/dist/shared/validate-CFx2dISs.cjs.map +1 -0
  60. package/dist/shared/{errors-GI10kZxk.js → validate-D7_kq3g5.js} +55 -6
  61. package/dist/shared/validate-D7_kq3g5.js.map +1 -0
  62. package/dist/web/index.cjs +4 -4
  63. package/dist/web/index.cjs.map +1 -1
  64. package/dist/web/index.js +3 -3
  65. package/package.json +1 -1
  66. package/dist/shared/errors-BmP3TKoW.cjs.map +0 -1
  67. package/dist/shared/errors-GI10kZxk.js.map +0 -1
  68. package/dist/shared/events-BVfqWyBw.cjs.map +0 -1
  69. package/dist/shared/events-Cwj7UPAa.js.map +0 -1
  70. package/dist/shared/key-value-store-B5GYVEYZ.cjs.map +0 -1
  71. package/dist/shared/key-value-store-Bt2PSMxY.js.map +0 -1
  72. package/dist/shared/multi-storage-C3Ngtop1.js.map +0 -1
  73. package/dist/shared/multi-storage-s-fPtjoy.cjs.map +0 -1
  74. package/dist/shared/redact-DJV0XM3n.js.map +0 -1
  75. package/dist/shared/redact-lAmck1Z6.cjs.map +0 -1
  76. package/dist/shared/render-BT74nC1l.js.map +0 -1
  77. package/dist/shared/render-CYJSDypQ.cjs.map +0 -1
  78. package/dist/shared/url-BnO1aWsd.cjs.map +0 -1
  79. package/dist/shared/url-DS0-lrl-.js.map +0 -1
@@ -1,5 +1,5 @@
1
- import { O as isItdRateLimitError, _ as ItdStateError, a as ItdConfigError, b as isItdApiError, d as ItdForbiddenError, f as ItdNetworkError, g as ItdServerError, h as ItdRateLimitError, i as ItdAuthError, l as ItdFileError, m as ItdPhoneVerificationError, n as ItdApiError, o as ItdConflictError, p as ItdNotFoundError, s as ItdError, t as ItdAbortError, v as ItdTimeoutError, y as ItdValidationError } from "./errors-GI10kZxk.js";
2
- import { C as shouldSendCredentials, S as resolveFetch, _ as originOf, b as isBlob, d as buildQuery, g as normalizeBaseUrl, h as joinUrl, i as redactHeaders, m as isSameSite, n as redactBody, p as hostOf, s as CookieJar, v as RuntimeMode, w as shouldUseCookieJar, y as createDeviceId } from "./redact-DJV0XM3n.js";
1
+ import { C as ItdValidationError, N as isItdRateLimitError, S as ItdTimeoutError, T as isItdApiError, _ as ItdNotFoundError, a as ItdAbortError, b as ItdServerError, c as ItdAuthError, d as ItdError, g as ItdNetworkError, h as ItdForbiddenError, i as requireOptionalBoolean, l as ItdConfigError, n as isRecord, o as ItdApiError, p as ItdFileError, r as requireNonNegative, t as isObjectLike, u as ItdConflictError, v as ItdPhoneVerificationError, w as TimeoutBudget, x as ItdStateError, y as ItdRateLimitError } from "./validate-D7_kq3g5.js";
2
+ import { C as shouldSendCredentials, E as LogLevel, O as isLevelEnabled, S as resolveFetch, _ as originOf, b as isBlob, d as buildQuery, g as normalizeBaseUrl, h as joinUrl, i as redactHeaders, k as resolveLogger, m as isSameSite, n as redactBody, p as hostOf, s as CookieJar, v as RuntimeMode, w as shouldUseCookieJar, y as createDeviceId } from "./redact-C5oxIvcc.js";
3
3
  //#region src/core/async-dispose.ts
4
4
  /**
5
5
  * Подставляет метод `await using` там, где `Symbol.asyncDispose` отсутствует.
@@ -62,30 +62,9 @@ const RateLimitPacing = Object.freeze({
62
62
  Off: "off"
63
63
  });
64
64
  //#endregion
65
- //#region src/core/validate.ts
66
- /**
67
- * Проверки, общие для резолверов конфигурации.
68
- *
69
- * Настройки исполнения и настройки сессии разбираются в разных модулях, но сообщать
70
- * об ошибке должны одинаково: пользователю всё равно, какой слой отверг его значение.
71
- */
72
- /** Похоже ли значение на объект настроек, а не на массив или `null`. */
73
- function isRecord$1(value) {
74
- return typeof value === "object" && value !== null && !Array.isArray(value);
75
- }
76
- /** @throws {ItdConfigError} если значение задано и не является неотрицательным числом */
77
- function requirePositive(value, name) {
78
- if (!Number.isFinite(value) || value < 0) throw new ItdConfigError(`${name} должен быть неотрицательным числом, получено: ${value}`);
79
- return value;
80
- }
81
- /** @throws {ItdConfigError} если значение задано и не является boolean */
82
- function requireOptionalBoolean(value, name) {
83
- if (value !== void 0 && typeof value !== "boolean") throw new ItdConfigError(`${name} должен быть boolean`);
84
- }
85
- //#endregion
86
65
  //#region src/core/version.ts
87
66
  /** Версия библиотеки. Попадает в `User-Agent`. */
88
- const LIBRARY_VERSION = "0.10.3";
67
+ const LIBRARY_VERSION = "0.11.0";
89
68
  //#endregion
90
69
  //#region src/core/config.ts
91
70
  /** Базовый URL API итд.com. Домен записан в punycode: `итд.com`. */
@@ -120,13 +99,13 @@ const DEFAULT_RATE_LIMIT_DELAYS = Object.freeze([
120
99
  ]);
121
100
  function resolveHeaders(headers) {
122
101
  if (headers === void 0) return {};
123
- if (!isRecord$1(headers)) throw new ItdConfigError("headers должен быть объектом строк");
102
+ if (!isObjectLike(headers)) throw new ItdConfigError("headers должен быть объектом строк");
124
103
  for (const [name, value] of Object.entries(headers)) if (typeof value !== "string") throw new ItdConfigError(`headers.${name} должен быть строкой`);
125
104
  return { ...headers };
126
105
  }
127
106
  function resolveHooks(hooks) {
128
107
  if (hooks === void 0) return {};
129
- if (!isRecord$1(hooks)) throw new ItdConfigError("hooks должен быть объектом");
108
+ if (!isObjectLike(hooks)) throw new ItdConfigError("hooks должен быть объектом");
130
109
  for (const name of [
131
110
  "onRequest",
132
111
  "onResponse",
@@ -135,27 +114,6 @@ function resolveHooks(hooks) {
135
114
  ]) if (hooks[name] !== void 0 && typeof hooks[name] !== "function") throw new ItdConfigError(`hooks.${name} должен быть функцией`);
136
115
  return { ...hooks };
137
116
  }
138
- function resolveLogger(logger) {
139
- if (logger === void 0 || logger === false) return void 0;
140
- if (logger === true) return consoleLogger();
141
- if (!isRecord$1(logger)) throw new ItdConfigError("logger должен быть boolean или объектом Logger");
142
- for (const method of [
143
- "debug",
144
- "info",
145
- "warn",
146
- "error"
147
- ]) if (typeof logger[method] !== "function") throw new ItdConfigError(`logger.${method} должен быть функцией`);
148
- return logger;
149
- }
150
- /** Логгер поверх `console` — включается опцией `logger: true`. */
151
- function consoleLogger() {
152
- return {
153
- debug: (message, ...args) => console.debug(`[itd-api] ${message}`, ...args),
154
- info: (message, ...args) => console.info(`[itd-api] ${message}`, ...args),
155
- warn: (message, ...args) => console.warn(`[itd-api] ${message}`, ...args),
156
- error: (message, ...args) => console.error(`[itd-api] ${message}`, ...args)
157
- };
158
- }
159
117
  /**
160
118
  * Приводит настройки повторов к полному виду.
161
119
  *
@@ -167,15 +125,15 @@ function consoleLogger() {
167
125
  */
168
126
  function resolveRetry(retry) {
169
127
  if (retry === false) return void 0;
170
- if (retry !== void 0 && !isRecord$1(retry)) throw new ItdConfigError("retry должен быть объектом или false");
128
+ if (retry !== void 0 && !isObjectLike(retry)) throw new ItdConfigError("retry должен быть объектом или false");
171
129
  const options = retry ?? {};
172
130
  const attempts = options.attempts ?? 3;
173
131
  if (!Number.isInteger(attempts) || attempts < 1) throw new ItdConfigError(`retry.attempts должен быть целым числом от 1, получено: ${attempts}`);
174
132
  const jitter = options.jitter ?? .3;
175
133
  if (!Number.isFinite(jitter) || jitter < 0 || jitter > 1) throw new ItdConfigError(`retry.jitter должен быть в диапазоне 0…1, получено: ${jitter}`);
176
134
  if (options.shouldRetry !== void 0 && typeof options.shouldRetry !== "function") throw new ItdConfigError("retry.shouldRetry должен быть функцией");
177
- const baseDelay = requirePositive(options.baseDelay ?? 500, "retry.baseDelay");
178
- const maxDelay = requirePositive(options.maxDelay ?? 3e4, "retry.maxDelay");
135
+ const baseDelay = requireNonNegative(options.baseDelay ?? 500, "retry.baseDelay");
136
+ const maxDelay = requireNonNegative(options.maxDelay ?? 3e4, "retry.maxDelay");
179
137
  if (attempts === 1) return void 0;
180
138
  return {
181
139
  attempts,
@@ -223,10 +181,10 @@ function resolvePacing(pacing) {
223
181
  function resolveBucketOverrides(overrides, bucket, catalog) {
224
182
  const builtIn = catalog.bucketOverrides;
225
183
  if (overrides === void 0) return builtIn;
226
- if (!isRecord$1(overrides)) throw new ItdConfigError("rateLimit.bucketOverrides должен быть объектом");
184
+ if (!isObjectLike(overrides)) throw new ItdConfigError("rateLimit.bucketOverrides должен быть объектом");
227
185
  const resolved = { ...builtIn };
228
186
  for (const [name, override] of Object.entries(overrides)) {
229
- if (!isRecord$1(override)) throw new ItdConfigError(`rateLimit.bucketOverrides.${name} должен быть объектом`);
187
+ if (!isObjectLike(override)) throw new ItdConfigError(`rateLimit.bucketOverrides.${name} должен быть объектом`);
230
188
  assertKnownBucket(name, "rateLimit.bucketOverrides", bucket, catalog);
231
189
  if (override.concurrency !== void 0) requireConcurrency(override.concurrency, `rateLimit.bucketOverrides.${name}.concurrency`);
232
190
  if (override.limit !== void 0 && (!Number.isFinite(override.limit) || override.limit <= 0)) throw new ItdConfigError(`rateLimit.bucketOverrides.${name}.limit должен быть положительным числом, получено: ${override.limit}`);
@@ -252,7 +210,7 @@ function resolveBucketOverrides(overrides, bucket, catalog) {
252
210
  */
253
211
  function resolveRateLimit(rateLimit, catalog) {
254
212
  if (rateLimit === false) return void 0;
255
- if (rateLimit !== void 0 && !isRecord$1(rateLimit)) throw new ItdConfigError("rateLimit должен быть объектом или false");
213
+ if (rateLimit !== void 0 && !isObjectLike(rateLimit)) throw new ItdConfigError("rateLimit должен быть объектом или false");
256
214
  const defaults = {
257
215
  concurrency: 6,
258
216
  rps: void 0,
@@ -262,7 +220,6 @@ function resolveRateLimit(rateLimit, catalog) {
262
220
  bucketConcurrency: 6,
263
221
  bucketOverrides: catalog.bucketOverrides,
264
222
  bucket: void 0,
265
- bucketLimits: catalog.bucketLimits,
266
223
  defaultBucket: catalog.defaultBucket
267
224
  };
268
225
  if (!rateLimit) return defaults;
@@ -270,7 +227,7 @@ function resolveRateLimit(rateLimit, catalog) {
270
227
  if (rateLimit.rps !== void 0) requireRps(rateLimit.rps, "rateLimit.rps");
271
228
  const retryDelays = rateLimit.retryDelays ?? defaults.retryDelays;
272
229
  if (!Array.isArray(retryDelays)) throw new ItdConfigError("rateLimit.retryDelays должен быть массивом чисел");
273
- for (const delay of retryDelays) requirePositive(delay, "rateLimit.retryDelays");
230
+ for (const delay of retryDelays) requireNonNegative(delay, "rateLimit.retryDelays");
274
231
  requireOptionalBoolean(rateLimit.buckets, "rateLimit.buckets");
275
232
  if (rateLimit.bucket !== void 0 && typeof rateLimit.bucket !== "function") throw new ItdConfigError("rateLimit.bucket должен быть функцией");
276
233
  return {
@@ -282,7 +239,6 @@ function resolveRateLimit(rateLimit, catalog) {
282
239
  bucketConcurrency: requireConcurrency(rateLimit.bucketConcurrency ?? concurrency, "rateLimit.bucketConcurrency"),
283
240
  bucketOverrides: resolveBucketOverrides(rateLimit.bucketOverrides, rateLimit.bucket, catalog),
284
241
  bucket: rateLimit.bucket,
285
- bucketLimits: catalog.bucketLimits,
286
242
  defaultBucket: catalog.defaultBucket
287
243
  };
288
244
  }
@@ -292,13 +248,13 @@ function resolveRateLimit(rateLimit, catalog) {
292
248
  */
293
249
  function resolveServices(services) {
294
250
  if (services === void 0) return [];
295
- if (!isRecord$1(services)) throw new ItdConfigError("services должен быть объектом");
251
+ if (!isObjectLike(services)) throw new ItdConfigError("services должен быть объектом");
296
252
  return Object.entries(services).map(([name, value]) => {
297
253
  if (typeof value === "string") return {
298
254
  name,
299
255
  baseUrl: value
300
256
  };
301
- if (!isRecord$1(value)) throw new ItdConfigError(`services.${name} должен быть URL или объектом сервиса`);
257
+ if (!isObjectLike(value)) throw new ItdConfigError(`services.${name} должен быть URL или объектом сервиса`);
302
258
  return {
303
259
  ...value,
304
260
  name
@@ -315,11 +271,12 @@ function resolveServices(services) {
315
271
  * @throws {ItdConfigError} при некорректных значениях
316
272
  */
317
273
  function resolveRuntimeConfig(options, catalog) {
318
- if (!isRecord$1(options)) throw new ItdConfigError("опции клиента должны быть объектом");
274
+ if (!isObjectLike(options)) throw new ItdConfigError("опции клиента должны быть объектом");
319
275
  const mode = options.mode ?? RuntimeMode.Auto;
320
276
  if (!Object.values(RuntimeMode).includes(mode)) throw new ItdConfigError(`mode должен быть одним из ${Object.values(RuntimeMode).join(", ")}, получено: ${mode}`);
321
- const timeout = requirePositive(options.timeout ?? 3e4, "timeout");
322
- const shutdownTimeout = requirePositive(options.shutdownTimeout ?? 1e4, "shutdownTimeout");
277
+ const timeout = requireNonNegative(options.timeout ?? 3e4, "timeout");
278
+ const deadline = requireNonNegative(options.deadline ?? 0, "deadline");
279
+ const shutdownTimeout = requireNonNegative(options.shutdownTimeout ?? 1e4, "shutdownTimeout");
323
280
  if (options.clock !== void 0 && (typeof options.clock !== "object" || options.clock === null || typeof options.clock.now !== "function" || typeof options.clock.schedule !== "function")) throw new ItdConfigError("clock должен предоставлять методы now() и schedule()");
324
281
  if (options.userAgent !== void 0 && options.userAgent !== false && typeof options.userAgent !== "string") throw new ItdConfigError("userAgent должен быть строкой или false");
325
282
  return {
@@ -328,6 +285,7 @@ function resolveRuntimeConfig(options, catalog) {
328
285
  fetch: resolveFetch(options.fetch),
329
286
  clock: options.clock ?? systemClock,
330
287
  timeout,
288
+ deadline,
331
289
  shutdownTimeout,
332
290
  retry: resolveRetry(options.retry),
333
291
  rateLimit: resolveRateLimit(options.rateLimit, catalog),
@@ -358,7 +316,7 @@ function withAttemptInterceptorScope(request, interceptors) {
358
316
  }
359
317
  /** Читает снимок attempt interceptors логической операции. @internal */
360
318
  function attemptInterceptorScope(request) {
361
- return request[ATTEMPT_SCOPE] ?? [];
319
+ return request[ATTEMPT_SCOPE];
362
320
  }
363
321
  /**
364
322
  * Выполняет обёртки одной попытки.
@@ -366,8 +324,8 @@ function attemptInterceptorScope(request) {
366
324
  * Каждый `next` одноразовый: interceptor может short-circuit попытку синтетическим
367
325
  * `Response`, но не может незаметно породить два сетевых запроса внутри одного attempt.
368
326
  */
369
- async function runAttemptInterceptors(request, context, execute) {
370
- return attemptInterceptorScope(request).reduceRight((next, { plugin, interceptor }) => async () => {
327
+ async function runAttemptInterceptors(interceptors, context, execute) {
328
+ return interceptors.reduceRight((next, { plugin, interceptor }) => async () => {
371
329
  let called = false;
372
330
  const response = await interceptor(context, () => {
373
331
  if (called) throw new ItdConfigError(`attempt interceptor плагина «${plugin}» вызвал next() больше одного раза`);
@@ -525,9 +483,13 @@ var PluginRegistry = class {
525
483
  }
526
484
  /** Проверяет добавление без вызова `install()`. @internal */
527
485
  assertCanAdd(plugin) {
486
+ this.#orderWith(plugin);
487
+ }
488
+ /** Проверяет плагин и возвращает порядок обёрток вместе с ним. */
489
+ #orderWith(plugin) {
528
490
  validatePluginDefinition(plugin);
529
491
  if (this.#removing.has(plugin.name)) throw new ItdConfigError(`плагин «${plugin.name}» ещё отключается; дождитесь завершения unuse() или dispose()`);
530
- orderPluginDefinitions([...this.#ordered.map((entry) => entry.plugin), plugin]);
492
+ return orderPluginDefinitions([...this.#ordered.map((entry) => entry.plugin), plugin]);
531
493
  }
532
494
  /** Проверяет удаление без изменения реестра. @internal */
533
495
  assertCanRemove(name) {
@@ -543,8 +505,7 @@ var PluginRegistry = class {
543
505
  * @throws {ItdConfigError} если плагин задан неверно, уже подключён или нарушает зависимости
544
506
  */
545
507
  add(plugin, context) {
546
- this.assertCanAdd(plugin);
547
- const ordered = orderPluginDefinitions([...this.#ordered.map((entry) => entry.plugin), plugin]);
508
+ const ordered = this.#orderWith(plugin);
548
509
  const transformers = [];
549
510
  const interceptors = [];
550
511
  const register = (values, value, kind) => {
@@ -787,15 +748,10 @@ var DestinationScheduler = class {
787
748
  register(bucket) {
788
749
  this.#buckets.push(bucket);
789
750
  }
790
- /** Пересчитывает ближайший запуск после изменения очереди или ограничения. */
751
+ /** Пересчитывает запуск; существующий таймер заменяется только более ранним. */
791
752
  changed() {
792
753
  this.#drain();
793
754
  }
794
- /** Перепланирует пробуждение после изменения времени готовности бакета. */
795
- timingChanged() {
796
- this.#cancelWakeup();
797
- this.#drain();
798
- }
799
755
  /** Отклоняет ожидающие задачи, сохраняя паузы и темп для повторного запуска клиента. */
800
756
  stop() {
801
757
  this.#cancelWakeup();
@@ -851,6 +807,70 @@ var DestinationScheduler = class {
851
807
  }
852
808
  };
853
809
  /**
810
+ * Очередь ожидающих задач в порядке постановки.
811
+ *
812
+ * Постановка в хвост, снятие головы, снятие произвольного узла при отмене и полная
813
+ * очистка — за O(1) при любой длине: очередь рассчитана на десятки тысяч задач, разом
814
+ * упёршихся в паузу `429`.
815
+ */
816
+ var WaitingList = class {
817
+ #head;
818
+ #tail;
819
+ #size = 0;
820
+ get size() {
821
+ return this.#size;
822
+ }
823
+ push(task) {
824
+ const node = {
825
+ task,
826
+ prev: this.#tail,
827
+ next: void 0,
828
+ linked: true
829
+ };
830
+ if (this.#tail) this.#tail.next = node;
831
+ else this.#head = node;
832
+ this.#tail = node;
833
+ this.#size += 1;
834
+ return node;
835
+ }
836
+ shift() {
837
+ const node = this.#head;
838
+ if (!node) return void 0;
839
+ this.remove(node);
840
+ return node.task;
841
+ }
842
+ /** Снимает узел; `false`, если он уже снят. */
843
+ remove(node) {
844
+ if (!node.linked) return false;
845
+ node.linked = false;
846
+ if (node.prev) node.prev.next = node.next;
847
+ else this.#head = node.next;
848
+ if (node.next) node.next.prev = node.prev;
849
+ else this.#tail = node.prev;
850
+ node.prev = void 0;
851
+ node.next = void 0;
852
+ this.#size -= 1;
853
+ return true;
854
+ }
855
+ /** Снимает все узлы; задачи возвращаются в порядке постановки. */
856
+ drain() {
857
+ const tasks = [];
858
+ let node = this.#head;
859
+ while (node) {
860
+ const following = node.next;
861
+ node.linked = false;
862
+ node.prev = void 0;
863
+ node.next = void 0;
864
+ tasks.push(node.task);
865
+ node = following;
866
+ }
867
+ this.#head = void 0;
868
+ this.#tail = void 0;
869
+ this.#size = 0;
870
+ return tasks;
871
+ }
872
+ };
873
+ /**
854
874
  * Состояние и очередь одного серверного бакета.
855
875
  *
856
876
  * Класс хранит локальную конкурентность, темп и серверную квоту, а решение о старте
@@ -879,12 +899,14 @@ var BucketQueue = class {
879
899
  #flatPause;
880
900
  /** Лимит бакета до первого ответа. */
881
901
  #seedLimit;
902
+ /** Начальные ограничения из каталога, с которыми создана очередь. */
903
+ #definition;
882
904
  /** Последнее, что сказал сервер. Живёт и в режиме `off` — ради `rateLimitState()`. */
883
905
  #limit;
884
906
  #remaining;
885
907
  #active = 0;
886
908
  #nextSlot = 0;
887
- #waiting = [];
909
+ #waiting = new WaitingList();
888
910
  /**
889
911
  * Оценка остатка для режима `smooth`.
890
912
  *
@@ -893,17 +915,19 @@ var BucketQueue = class {
893
915
  */
894
916
  #tokens = 1;
895
917
  #tokensAt;
896
- constructor(destination, bucket, scheduler, options, clock, featureDefinition) {
918
+ constructor(destination, bucket, scheduler, options, clock, definition) {
897
919
  this.#destination = destination;
898
920
  this.#bucket = bucket;
899
921
  this.#scheduler = scheduler;
900
922
  this.#clock = clock;
923
+ this.#definition = definition;
901
924
  this.#pacing = options.pacing;
902
925
  this.#smooth = options.buckets && options.pacing === RateLimitPacing.Smooth;
903
926
  this.#flatPause = options.buckets ? void 0 : options.retryDelays[0] ?? 0;
904
- this.#seedLimit = options.buckets ? featureDefinition?.limit ?? seedLimit(bucket, options) : void 0;
905
- this.#concurrency = options.buckets ? featureDefinition?.concurrency ?? options.bucketOverrides[bucket]?.concurrency ?? options.bucketConcurrency : options.concurrency;
906
- const rps = options.buckets ? featureDefinition?.rps ?? options.bucketOverrides[bucket]?.rps : void 0;
927
+ const override = options.buckets ? options.bucketOverrides[bucket] : void 0;
928
+ this.#seedLimit = options.buckets ? override?.limit ?? definition?.limit : void 0;
929
+ this.#concurrency = options.buckets ? override?.concurrency ?? definition?.concurrency ?? options.bucketConcurrency : options.concurrency;
930
+ const rps = options.buckets ? override?.rps ?? definition?.rps : void 0;
907
931
  this.#minGap = rps ? 1e3 / rps : 0;
908
932
  scheduler.register(this);
909
933
  }
@@ -911,17 +935,21 @@ var BucketQueue = class {
911
935
  get bucket() {
912
936
  return this.#bucket;
913
937
  }
938
+ /** Начальные ограничения из каталога, с которыми создана очередь. */
939
+ get definition() {
940
+ return this.#definition;
941
+ }
914
942
  /** Запросов бакета прошло в общую очередь и ещё не завершилось. */
915
943
  get active() {
916
944
  return this.#active;
917
945
  }
918
946
  /** Запросов бакета ждёт своей очереди. */
919
947
  get pending() {
920
- return this.#waiting.length;
948
+ return this.#waiting.size;
921
949
  }
922
950
  /** Есть ли ожидающая задача, для которой свободен локальный слот. */
923
951
  get canStart() {
924
- return this.#waiting.length > 0 && this.#active < this.#concurrency;
952
+ return this.#waiting.size > 0 && this.#active < this.#concurrency;
925
953
  }
926
954
  /** Момент, раньше которого локальное ограничение не разрешает следующий старт. */
927
955
  get readyAt() {
@@ -934,13 +962,11 @@ var BucketQueue = class {
934
962
  let queued;
935
963
  const detach = () => signal?.removeEventListener("abort", onAbort);
936
964
  const onAbort = () => {
937
- const index = this.#waiting.indexOf(queued);
938
- if (index < 0) return;
939
- this.#waiting.splice(index, 1);
940
- queued.cancel(queueAbortError());
941
- this.#scheduler.timingChanged();
965
+ if (!this.#waiting.remove(queued)) return;
966
+ queued.task.cancel(queueAbortError());
967
+ this.#scheduler.changed();
942
968
  };
943
- queued = {
969
+ queued = this.#waiting.push({
944
970
  start: (complete) => {
945
971
  detach();
946
972
  Promise.resolve().then(task).then(resolve, reject).finally(complete);
@@ -949,8 +975,7 @@ var BucketQueue = class {
949
975
  detach();
950
976
  reject(reason);
951
977
  }
952
- };
953
- this.#waiting.push(queued);
978
+ });
954
979
  signal?.addEventListener("abort", onAbort, { once: true });
955
980
  if (signal?.aborted) onAbort();
956
981
  else this.#scheduler.changed();
@@ -994,7 +1019,7 @@ var BucketQueue = class {
994
1019
  this.#refill(capacity);
995
1020
  if (remaining < this.#tokens) this.#tokens = remaining;
996
1021
  const wait = this.#armPause(capacity);
997
- this.#scheduler.timingChanged();
1022
+ this.#scheduler.changed();
998
1023
  return wait;
999
1024
  }
1000
1025
  if (remaining > 0) return 0;
@@ -1018,7 +1043,7 @@ var BucketQueue = class {
1018
1043
  limit: this.#limit,
1019
1044
  remaining: this.#remaining,
1020
1045
  active: this.#active,
1021
- pending: this.#waiting.length
1046
+ pending: this.#waiting.size
1022
1047
  };
1023
1048
  }
1024
1049
  /** Отклоняет ещё не начатые задачи этого бакета. */
@@ -1027,9 +1052,8 @@ var BucketQueue = class {
1027
1052
  }
1028
1053
  /** Отклоняет ожидающие задачи; используется также при остановке всего направления. */
1029
1054
  cancelWaiting(reason, notify = true) {
1030
- const pending = this.#waiting.splice(0, this.#waiting.length);
1031
- for (const task of pending) task.cancel(reason);
1032
- if (notify) this.#scheduler.timingChanged();
1055
+ for (const task of this.#waiting.drain()) task.cancel(reason);
1056
+ if (notify) this.#scheduler.changed();
1033
1057
  }
1034
1058
  /** Лимит бакета: сказанный сервером, иначе табличный. */
1035
1059
  #capacity() {
@@ -1063,14 +1087,21 @@ var BucketQueue = class {
1063
1087
  #hold(ms) {
1064
1088
  if (ms <= 0) return;
1065
1089
  this.#nextSlot = Math.max(this.#nextSlot, this.#clock.now() + ms);
1066
- this.#scheduler.timingChanged();
1090
+ this.#scheduler.changed();
1067
1091
  }
1068
1092
  };
1069
- /** Лимит бакета до первого ответа: поправка пользователя важнее табличного значения. */
1070
- function seedLimit(bucket, options) {
1071
- const override = options.bucketOverrides[bucket]?.limit;
1072
- if (override !== void 0) return override;
1073
- return options.bucketLimits[bucket];
1093
+ /** Совпадают ли начальные ограничения по всем трём полям. */
1094
+ function sameDefinition(left, right) {
1095
+ return left?.limit === right?.limit && left?.concurrency === right?.concurrency && left?.rps === right?.rps;
1096
+ }
1097
+ /** Ограничения бакета для текста ошибки. */
1098
+ function describeDefinition(definition) {
1099
+ const parts = [
1100
+ definition?.limit === void 0 ? [] : [`limit ${definition.limit}`],
1101
+ definition?.concurrency === void 0 ? [] : [`concurrency ${definition.concurrency}`],
1102
+ definition?.rps === void 0 ? [] : [`rps ${definition.rps}`]
1103
+ ].flat();
1104
+ return parts.length > 0 ? parts.join(", ") : "без ограничений";
1074
1105
  }
1075
1106
  /**
1076
1107
  * Очереди по парам «направление — серверный счётчик частоты».
@@ -1086,47 +1117,23 @@ var RequestQueuePool = class {
1086
1117
  #clock;
1087
1118
  /** Ключ `undefined` — основная очередь внутренних клиентов без известного направления. */
1088
1119
  #destinations = /* @__PURE__ */ new Map();
1089
- /** Динамические определения feature вместе с числом использующих их клиентов. */
1090
- #featureBuckets = /* @__PURE__ */ new Map();
1091
1120
  constructor(options, clock = systemClock) {
1092
1121
  this.#options = options;
1093
1122
  this.#clock = clock;
1094
1123
  }
1095
1124
  /**
1096
- * Регистрирует бакет подключаемого feature.
1125
+ * Очередь бакета на направлении. При `buckets: false` бакет всегда `default`.
1126
+ *
1127
+ * Начальные ограничения приходят из каталога вызывающего клиента и запоминаются при
1128
+ * создании очереди. Клиенты, разделяющие один пул через `ItdAccounts`, обязаны описывать
1129
+ * бакет одинаково: расхождение обнаруживается при обращении к уже созданной очереди.
1097
1130
  *
1098
- * Повтор той же декларации разрешён клиентам, разделяющим один pool через `ItdAccounts`.
1099
- * Возвращённая функция откатывает регистрацию, пока очередь бакета ещё не создана.
1131
+ * @throws {ItdConfigError} если очередь бакета создана с другими ограничениями
1100
1132
  */
1101
- defineBucket(name, definition) {
1102
- const normalized = Object.freeze({
1103
- ...definition.limit === void 0 ? {} : { limit: definition.limit },
1104
- ...definition.concurrency === void 0 ? {} : { concurrency: definition.concurrency },
1105
- ...definition.rps === void 0 ? {} : { rps: definition.rps }
1106
- });
1107
- const existing = this.#featureBuckets.get(name);
1108
- if (existing) {
1109
- if (existing.definition.limit !== normalized.limit || existing.definition.concurrency !== normalized.concurrency || existing.definition.rps !== normalized.rps) throw new ItdConfigError(`Бакет feature «${name}» уже зарегистрирован с другими ограничениями`);
1110
- existing.references += 1;
1111
- } else this.#featureBuckets.set(name, {
1112
- definition: normalized,
1113
- references: 1
1114
- });
1115
- let released = false;
1116
- return () => {
1117
- if (released) return;
1118
- released = true;
1119
- const current = this.#featureBuckets.get(name);
1120
- if (!current) return;
1121
- current.references -= 1;
1122
- if (current.references > 0) return;
1123
- if (![...this.#destinations.values()].some((entry) => entry.buckets.has(name))) this.#featureBuckets.delete(name);
1124
- };
1125
- }
1126
- /** Очередь бакета на направлении. При `buckets: false` бакет всегда `default`. */
1127
- for(destination, bucket) {
1133
+ for(destination, bucket, definition) {
1128
1134
  const fallback = this.#options.defaultBucket;
1129
1135
  const name = this.#options.buckets ? bucket ?? fallback : fallback;
1136
+ const limits = this.#options.buckets ? definition : void 0;
1130
1137
  let entry = this.#destinations.get(destination);
1131
1138
  if (!entry) {
1132
1139
  entry = {
@@ -1137,9 +1144,9 @@ var RequestQueuePool = class {
1137
1144
  }
1138
1145
  let queue = entry.buckets.get(name);
1139
1146
  if (!queue) {
1140
- queue = new BucketQueue(destination, name, entry.scheduler, this.#options, this.#clock, this.#featureBuckets.get(name)?.definition);
1147
+ queue = new BucketQueue(destination, name, entry.scheduler, this.#options, this.#clock, limits);
1141
1148
  entry.buckets.set(name, queue);
1142
- }
1149
+ } else if (!sameDefinition(queue.definition, limits)) throw new ItdConfigError(`Бакет «${name}» уже используется с другими ограничениями: ${describeDefinition(queue.definition)} против ${describeDefinition(limits)}`);
1143
1150
  return queue;
1144
1151
  }
1145
1152
  /** Снимки всех бакетов, о которых что-то известно. */
@@ -1159,7 +1166,6 @@ var RequestQueuePool = class {
1159
1166
  clear() {
1160
1167
  this.stop();
1161
1168
  this.#destinations.clear();
1162
- this.#featureBuckets.clear();
1163
1169
  }
1164
1170
  };
1165
1171
  //#endregion
@@ -1299,10 +1305,17 @@ async function dispatchRequestHook(hooks, field, context) {
1299
1305
  }
1300
1306
  //#endregion
1301
1307
  //#region src/core/execution/lifecycle.ts
1302
- /** Объединяет отмену вызова, завершение клиента и общий таймаут логической операции. @internal */
1303
- function createRequestAbortScope(signal, lifetimeSignal, timeout, clock) {
1308
+ /**
1309
+ * Объединяет отмену вызова, завершение владельца и срок ожидания в один сигнал.
1310
+ *
1311
+ * Истечение срока отменяет сигнал ошибкой из `expiry.error`; она же становится
1312
+ * `signal.reason`.
1313
+ *
1314
+ * @internal
1315
+ */
1316
+ function createRequestAbortScope(signal, lifetimeSignal, clock, expiry) {
1304
1317
  const controller = new AbortController();
1305
- let timeoutReached = false;
1318
+ let expired;
1306
1319
  const link = (source) => {
1307
1320
  if (!source) return void 0;
1308
1321
  if (source.aborted) {
@@ -1314,20 +1327,33 @@ function createRequestAbortScope(signal, lifetimeSignal, timeout, clock) {
1314
1327
  return () => source.removeEventListener("abort", onAbort);
1315
1328
  };
1316
1329
  const detach = [link(signal), link(lifetimeSignal)];
1317
- const cancelTimer = timeout > 0 ? clock.schedule(() => {
1318
- timeoutReached = true;
1319
- controller.abort();
1320
- }, timeout) : void 0;
1330
+ let cancelTimer = expiry && expiry.after > 0 ? clock.schedule(() => {
1331
+ cancelTimer = void 0;
1332
+ if (controller.signal.aborted) return;
1333
+ expired = expiry.error();
1334
+ controller.abort(expired);
1335
+ }, expiry.after) : void 0;
1336
+ const disarm = () => {
1337
+ cancelTimer?.();
1338
+ cancelTimer = void 0;
1339
+ };
1321
1340
  return {
1322
1341
  signal: controller.signal,
1323
- timedOut: () => timeoutReached,
1342
+ abort: (reason) => controller.abort(reason),
1343
+ expired: () => expired,
1344
+ disarm,
1324
1345
  cleanup: () => {
1325
- cancelTimer?.();
1346
+ disarm();
1326
1347
  for (const remove of detach) remove?.();
1327
1348
  }
1328
1349
  };
1329
1350
  }
1330
- /** Прерывает только ожидание; исходный промис может корректно завершить общую фоновую работу. @internal */
1351
+ /**
1352
+ * Прерывает только ожидание: исходный промис может корректно завершить фоновую работу,
1353
+ * а его позднее отклонение не станет необработанным.
1354
+ *
1355
+ * @internal
1356
+ */
1331
1357
  function waitForRequest(promise, signal) {
1332
1358
  if (signal.aborted) {
1333
1359
  promise.catch(() => {});
@@ -1342,61 +1368,114 @@ function waitForRequest(promise, signal) {
1342
1368
  if (onAbort) signal.removeEventListener("abort", onAbort);
1343
1369
  });
1344
1370
  }
1345
- /** Возвращает публичную ошибку, соответствующую причине остановки операции. @internal */
1371
+ /**
1372
+ * Возвращает публичную ошибку, соответствующую причине остановки ожидания.
1373
+ *
1374
+ * Истёкший срок отдаёт ошибку, созданную таймером области. Отмена с причиной-ошибкой
1375
+ * библиотеки (истёкший срок родительской области) отдаёт саму причину; прочая отмена
1376
+ * оборачивается в {@link ItdAbortError}.
1377
+ *
1378
+ * @internal
1379
+ */
1346
1380
  function requestAbortError(scope, request, fallback) {
1347
- if (scope.timedOut()) return new ItdTimeoutError({
1348
- timeout: request.timeout,
1349
- method: request.method.toUpperCase(),
1350
- path: request.path
1351
- });
1352
- if (scope.signal.aborted && !(fallback instanceof ItdAbortError)) return new ItdAbortError(`Запрос ${request.method.toUpperCase()} ${request.path} отменён`, { cause: scope.signal.reason });
1353
- return fallback;
1381
+ const expired = scope.expired();
1382
+ if (expired) return expired;
1383
+ if (!scope.signal.aborted) return fallback;
1384
+ const reason = scope.signal.reason;
1385
+ if (reason instanceof ItdTimeoutError) return reason;
1386
+ if (fallback instanceof ItdAbortError) return fallback;
1387
+ return new ItdAbortError(`Запрос ${request.method.toUpperCase()} ${request.path} отменён`, { cause: reason });
1354
1388
  }
1355
1389
  //#endregion
1356
1390
  //#region src/core/execution/pipeline.ts
1357
- const REQUEST_ATTEMPT_STATE = Symbol("itd-api.request-attempt-state");
1358
- const REQUEST_QUEUE_KEY = Symbol("itd-api.request-queue-key");
1359
- const REQUEST_AUTH_RECOVERY_STATE = Symbol("itd-api.request-auth-recovery-state");
1360
- const REQUEST_ERROR_OBSERVATION_STATE = Symbol("itd-api.request-error-observation-state");
1361
- const DISPOSE_CLEANUP_REQUEST = Symbol("itd-api.dispose-cleanup-request");
1391
+ const OPERATION_STATE = Symbol("itd-api.operation-state");
1392
+ function operationState(request) {
1393
+ const internal = request;
1394
+ let state = internal[OPERATION_STATE];
1395
+ if (!state) {
1396
+ state = {};
1397
+ internal[OPERATION_STATE] = state;
1398
+ }
1399
+ return state;
1400
+ }
1401
+ /**
1402
+ * Начинает новую логическую операцию со свежим служебным состоянием.
1403
+ *
1404
+ * Контейнер заменяется безусловно: пользовательский код мог передать spread-копию запроса,
1405
+ * уже прошедшего через pipeline. Такое повторное обращение является новой операцией и не
1406
+ * должно наследовать её попытки, очередь, auth recovery, reader или учёт `onError`.
1407
+ *
1408
+ * @internal
1409
+ */
1410
+ function initializeOperationState(request, initial) {
1411
+ const state = {
1412
+ lifecycleSignal: initial.lifecycleSignal,
1413
+ ...initial.reader === void 0 ? {} : { reader: initial.reader },
1414
+ ...initial.disposeCleanup ? { disposeCleanup: true } : {}
1415
+ };
1416
+ return {
1417
+ ...request,
1418
+ [OPERATION_STATE]: state
1419
+ };
1420
+ }
1421
+ /** Функция чтения контракта операции, если запрос её несёт. @internal */
1422
+ function operationReaderOf(request) {
1423
+ return request[OPERATION_STATE]?.reader;
1424
+ }
1425
+ /** Переносит служебное состояние логической операции на новый объект запроса. @internal */
1426
+ function withOperationState(prepared, source) {
1427
+ const from = source;
1428
+ const to = prepared;
1429
+ to[OPERATION_STATE] = from[OPERATION_STATE] ?? {};
1430
+ return prepared;
1431
+ }
1432
+ /** Общий сигнал операции, сохранённый {@link initializeOperationState}. @internal */
1433
+ function lifecycleSignalOf(request) {
1434
+ return request[OPERATION_STATE]?.lifecycleSignal;
1435
+ }
1362
1436
  /** Возвращает общее для всех retry состояние восстановления авторизации. @internal */
1363
1437
  function requestAuthRecoveryState(request) {
1364
- const internal = request;
1365
- const current = internal[REQUEST_AUTH_RECOVERY_STATE];
1438
+ const state = operationState(request);
1439
+ const current = state.authRecovery;
1366
1440
  if (current) return current;
1367
- const state = {
1441
+ const recovery = {
1368
1442
  recovered: false,
1369
1443
  preparationErrors: /* @__PURE__ */ new Set()
1370
1444
  };
1371
- internal[REQUEST_AUTH_RECOVERY_STATE] = state;
1372
- return state;
1445
+ state.authRecovery = recovery;
1446
+ return recovery;
1373
1447
  }
1374
- /** Создаёт общую для копий логического запроса отметку вызова `onError`. @internal */
1375
- function trackRequestErrorObservation(request) {
1376
- const internal = request;
1377
- internal[REQUEST_ERROR_OBSERVATION_STATE] ??= {
1448
+ /** Создаёт общий для копий логического запроса учёт вызовов `onError`. @internal */
1449
+ function trackRequestErrorReporting(request) {
1450
+ const state = operationState(request);
1451
+ state.errorReporting ??= {
1378
1452
  errors: /* @__PURE__ */ new Set(),
1379
- lifecycleAbortedDuringNotification: false
1453
+ abortReported: false
1380
1454
  };
1381
1455
  }
1456
+ function errorReportingState(request) {
1457
+ trackRequestErrorReporting(request);
1458
+ return operationState(request).errorReporting;
1459
+ }
1382
1460
  /** Отмечает, что ошибка логического запроса уже была передана в `onError`. @internal */
1383
- function markRequestErrorObserved(request, error) {
1384
- trackRequestErrorObservation(request);
1385
- request[REQUEST_ERROR_OBSERVATION_STATE]?.errors.add(error);
1461
+ function markRequestErrorReported(request, error) {
1462
+ errorReportingState(request).errors.add(error);
1386
1463
  }
1387
1464
  /** Была ли конкретная ошибка этой логической операции уже передана в `onError`. @internal */
1388
- function wasRequestErrorObserved(request, error) {
1389
- return request[REQUEST_ERROR_OBSERVATION_STATE]?.errors.has(error) ?? false;
1390
- }
1391
- /** Отмечает отмену lifecycle во время уже начатого локального `onError`. @internal */
1392
- function markRequestErrorNotificationAborted(request) {
1393
- trackRequestErrorObservation(request);
1394
- const state = request[REQUEST_ERROR_OBSERVATION_STATE];
1395
- if (state) state.lifecycleAbortedDuringNotification = true;
1465
+ function wasRequestErrorReported(request, error) {
1466
+ return request[OPERATION_STATE]?.errorReporting?.errors.has(error) ?? false;
1396
1467
  }
1397
- /** Нужно ли верхней границе не запускать тот же `onError` повторно после отмены. @internal */
1398
- function wasRequestErrorNotificationAborted(request) {
1399
- return request[REQUEST_ERROR_OBSERVATION_STATE]?.lifecycleAbortedDuringNotification ?? false;
1468
+ /**
1469
+ * Отмечает, что отмена операции передана в `onError`.
1470
+ *
1471
+ * @returns `false`, если это уже сделал другой уровень — повторно вызывать хук не нужно
1472
+ * @internal
1473
+ */
1474
+ function claimAbortReport(request) {
1475
+ const state = errorReportingState(request);
1476
+ if (state.abortReported) return false;
1477
+ state.abortReported = true;
1478
+ return true;
1400
1479
  }
1401
1480
  /** Один раз присваивает низкоуровневому запросу семантический ID до входа в middleware. */
1402
1481
  function identifyRequest(request) {
@@ -1405,56 +1484,40 @@ function identifyRequest(request) {
1405
1484
  operationId: "raw"
1406
1485
  } : request;
1407
1486
  }
1408
- /** Привязывает счётчик транспортных попыток к одной логической операции. @internal */
1409
- function trackRequestAttempts(request) {
1410
- if (request[REQUEST_ATTEMPT_STATE]) return request;
1411
- return {
1412
- ...request,
1413
- [REQUEST_ATTEMPT_STATE]: { value: 0 }
1414
- };
1415
- }
1416
1487
  /** Начинает следующую фактическую транспортную попытку логической операции. @internal */
1417
1488
  function beginTransportAttempt(request) {
1418
- const tracked = trackRequestAttempts(request);
1419
- const state = tracked[REQUEST_ATTEMPT_STATE];
1420
- if (!state) throw new Error("request attempt state was not initialized");
1421
- state.value += 1;
1489
+ const state = operationState(request);
1490
+ state.attempt = (state.attempt ?? 0) + 1;
1422
1491
  return {
1423
- ...tracked,
1424
- attempt: state.value
1492
+ ...request,
1493
+ attempt: state.attempt
1425
1494
  };
1426
1495
  }
1427
1496
  /** Возвращает номер последней начатой транспортной попытки. @internal */
1428
1497
  function currentTransportAttempt(request) {
1429
- return request[REQUEST_ATTEMPT_STATE]?.value ?? 0;
1498
+ return request[OPERATION_STATE]?.attempt ?? 0;
1430
1499
  }
1431
1500
  /**
1432
1501
  * Вычисляет ключ очереди один раз на логическую операцию.
1433
1502
  *
1434
1503
  * Ключ спрашивают трижды: при постановке в очередь, при чтении заголовков ответа и при
1435
- * паузе после `429`. Значение пишется прямо в объект запроса слои ниже копируют его
1436
- * через spread, и перечислимое символьное поле переходит в копии.
1504
+ * паузе после `429`. Значение хранится в общем состоянии операции, поэтому его видят все
1505
+ * копии запроса ниже по конвейеру. Ограничения бакета фиксируются вместе с ключом:
1506
+ * освобождение feature во время запроса не меняет очередь операции.
1437
1507
  *
1438
1508
  * @internal
1439
1509
  */
1440
1510
  function requestQueueKey(request, compute) {
1441
- const internal = request;
1442
- const cached = internal[REQUEST_QUEUE_KEY];
1511
+ const state = operationState(request);
1512
+ const cached = state.queueKey;
1443
1513
  if (cached) return cached;
1444
1514
  const key = compute(request);
1445
- internal[REQUEST_QUEUE_KEY] = key;
1515
+ state.queueKey = key;
1446
1516
  return key;
1447
1517
  }
1448
- /** Помечает запрос как часть внутренней финализации уже начатого `dispose()`. @internal */
1449
- function markDisposeCleanupRequest(request) {
1450
- return {
1451
- ...request,
1452
- [DISPOSE_CLEANUP_REQUEST]: true
1453
- };
1454
- }
1455
1518
  /** Разрешено ли запросу завершать внутреннюю очистку после `dispose()`. @internal */
1456
1519
  function isDisposeCleanupRequest(request) {
1457
- return request[DISPOSE_CLEANUP_REQUEST] === true;
1520
+ return request[OPERATION_STATE]?.disposeCleanup === true;
1458
1521
  }
1459
1522
  /**
1460
1523
  * Собирает слои в один обработчик.
@@ -1484,129 +1547,143 @@ function withLayerHeaders(request, headers) {
1484
1547
  }
1485
1548
  //#endregion
1486
1549
  //#region src/core/execution/http.ts
1550
+ /** Собирает запрос конвейера из контракта операции и параметров вызова. */
1551
+ function operationRequest(operation, options) {
1552
+ return {
1553
+ ...options,
1554
+ operationId: operation.id,
1555
+ method: operation.method,
1556
+ retrySafety: options.retrySafety ?? operation.retrySafety
1557
+ };
1558
+ }
1487
1559
  /**
1488
1560
  * Точка входа ресурсов в конвейер запросов.
1489
1561
  *
1490
- * Принимает готовую цепочку обработки и предоставляет ресурсам методы `request`/`execute`.
1491
- * О слоях и их порядке ресурсы не знают.
1562
+ * Заводит общий lifecycle логической операции отмену, освобождение клиента и `deadline`, —
1563
+ * передаёт запрос собранной цепочке стадий и сообщает в `onError` об ошибках, которые
1564
+ * не дошли до транспорта. О стадиях и их порядке ресурсы не знают.
1492
1565
  */
1493
1566
  var HttpClient = class {
1494
1567
  #handler;
1495
- #plugins;
1496
1568
  #baseUrl;
1497
- #timeout;
1569
+ #deadline;
1498
1570
  #clock;
1499
1571
  #lifetimeSignal;
1500
1572
  #hooks;
1501
1573
  #assertActive;
1574
+ #activeScopes = /* @__PURE__ */ new Set();
1502
1575
  constructor(deps) {
1503
1576
  this.#handler = deps.handler;
1504
- this.#plugins = deps.plugins;
1505
1577
  this.#baseUrl = deps.baseUrl;
1506
- this.#timeout = deps.timeout;
1578
+ this.#deadline = deps.deadline;
1507
1579
  this.#clock = deps.clock;
1508
1580
  this.#lifetimeSignal = deps.lifetimeSignal;
1509
1581
  this.#hooks = deps.hooks;
1510
1582
  this.#assertActive = deps.assertActive;
1511
- }
1512
- /** Базовый URL, к которому обращается клиент. */
1513
- get baseUrl() {
1514
- return this.#baseUrl;
1583
+ if (this.#lifetimeSignal && !this.#lifetimeSignal.aborted) this.#lifetimeSignal.addEventListener("abort", () => {
1584
+ for (const scope of this.#activeScopes) scope.abort(this.#lifetimeSignal?.reason);
1585
+ }, { once: true });
1515
1586
  }
1516
1587
  /**
1517
- * Выполняет запрос к API через собранный конвейер.
1588
+ * Выполняет низкоуровневый запрос через собранный конвейер.
1518
1589
  *
1519
1590
  * @typeParam T ожидаемая форма ответа после снятия обёртки `{ data: … }`
1520
1591
  * @throws {ItdApiError} если сервер ответил статусом ≥ 400
1521
- * @throws {ItdTimeoutError} если истёк таймаут
1592
+ * @throws {ItdTimeoutError} если истёк `timeout` попытки или `deadline` операции
1522
1593
  * @throws {ItdAbortError} если запрос отменён через `signal`
1523
1594
  * @throws {ItdNetworkError} если запрос не дошёл до сервера
1524
1595
  */
1525
1596
  request(options) {
1526
- const request = identifyRequest(options);
1527
- return this.#runWithLifecycle(request, (prepared) => this.#handler(prepared));
1597
+ return this.#run(identifyRequest(options));
1528
1598
  }
1529
1599
  /** Выполняет контракт операции; `next()` плагина возвращает результат после `read`. */
1530
1600
  execute(operation, options) {
1531
- return this.#run(operation, options);
1532
- }
1533
- #run(operation, options) {
1534
- const request = identifyRequest({
1535
- ...options,
1536
- operationId: operation.id,
1537
- method: operation.method,
1538
- retrySafety: options.retrySafety ?? operation.retrySafety
1539
- });
1540
- return this.#runWithLifecycle(request, async (prepared) => operation.read(await this.#handler(prepared), prepared));
1601
+ return this.#run(operationRequest(operation, options), { reader: operation.read });
1541
1602
  }
1542
1603
  /** Выполняет внутреннюю операцию финализации после начала `ItdClient.dispose()`. @internal */
1543
1604
  cleanupOperation(operation, options) {
1544
- const request = identifyRequest({
1545
- ...options,
1546
- operationId: operation.id,
1547
- method: operation.method,
1548
- retrySafety: options.retrySafety ?? operation.retrySafety
1605
+ return this.#run(operationRequest(operation, options), {
1606
+ allowDisposed: true,
1607
+ reader: operation.read,
1608
+ disposeCleanup: true
1549
1609
  });
1550
- return this.#runWithLifecycle(request, async (prepared) => {
1551
- const cleanupRequest = markDisposeCleanupRequest(prepared);
1552
- return operation.read(await this.#handler(cleanupRequest), prepared);
1553
- }, true);
1554
1610
  }
1555
- async #runWithLifecycle(request, execute, allowDisposed = false) {
1556
- if (!allowDisposed) this.#assertActive?.();
1557
- const timeout = request.timeout ?? this.#timeout;
1558
- const scope = createRequestAbortScope(request.signal, this.#lifetimeSignal, timeout, this.#clock);
1611
+ async #run(request, options = {}) {
1612
+ if (!options.allowDisposed) this.#assertActive?.();
1613
+ const deadline = request.deadline ?? this.#deadline;
1614
+ const scope = createRequestAbortScope(request.signal, void 0, this.#clock, {
1615
+ after: deadline,
1616
+ error: () => new ItdTimeoutError({
1617
+ timeout: deadline,
1618
+ method: request.method.toUpperCase(),
1619
+ path: request.path,
1620
+ budget: TimeoutBudget.Deadline
1621
+ })
1622
+ });
1623
+ if (this.#lifetimeSignal?.aborted) scope.abort(this.#lifetimeSignal.reason);
1624
+ else this.#activeScopes.add(scope);
1559
1625
  const startedAt = this.#clock.now();
1560
- trackRequestErrorObservation(request);
1626
+ const tracked = initializeOperationState(request, {
1627
+ lifecycleSignal: scope.signal,
1628
+ reader: options.reader,
1629
+ disposeCleanup: options.disposeCleanup
1630
+ });
1561
1631
  try {
1562
- const pending = this.#plugins.run(request, (prepared) => execute({
1563
- ...prepared,
1564
- signal: scope.signal,
1565
- timeout: 0
1566
- }), scope.signal);
1567
- return await waitForRequest(Promise.resolve(pending), scope.signal);
1632
+ return await waitForRequest(this.#handler(tracked), scope.signal);
1568
1633
  } catch (error) {
1569
- const failure = requestAbortError(scope, {
1570
- timeout,
1571
- method: request.method,
1572
- path: request.path
1573
- }, error);
1574
- if (!(wasRequestErrorObserved(request, error) || scope.signal.aborted && wasRequestErrorNotificationAborted(request))) {
1575
- markRequestErrorObserved(request, error);
1576
- let headers;
1577
- try {
1578
- headers = new Headers({
1579
- ...request.layerHeaders,
1580
- ...request.headers
1581
- });
1582
- } catch {
1583
- headers = new Headers();
1584
- }
1585
- const notification = dispatchRequestHook(this.#hooks, "onError", {
1586
- operationId: request.operationId,
1587
- signal: scope.signal,
1588
- method: request.method.toUpperCase(),
1589
- path: request.path,
1590
- url: joinUrl(request.baseUrl ?? this.#baseUrl, request.path) + buildQuery(request.query),
1591
- headers,
1592
- attempt: request.attempt ?? 1,
1593
- duration: this.#clock.now() - startedAt,
1594
- error: failure
1595
- });
1596
- try {
1597
- await waitForRequest(notification, scope.signal);
1598
- } catch (hookError) {
1599
- if (!scope.signal.aborted) throw hookError;
1634
+ const failure = requestAbortError(scope, request, error);
1635
+ if (hasRequestHook(this.#hooks, "onError")) {
1636
+ if (!(wasRequestErrorReported(tracked, error) || wasRequestErrorReported(tracked, failure) || scope.signal.aborted && !claimAbortReport(tracked))) {
1637
+ markRequestErrorReported(tracked, error);
1638
+ markRequestErrorReported(tracked, failure);
1639
+ await this.#notifyError(tracked, scope.signal, startedAt, failure);
1600
1640
  }
1601
1641
  }
1602
1642
  throw failure;
1603
1643
  } finally {
1644
+ this.#activeScopes.delete(scope);
1604
1645
  scope.cleanup();
1605
1646
  }
1606
1647
  }
1648
+ /** Сообщает об ошибке, которую транспорт не видел: очередь, авторизация, плагины, отмена. */
1649
+ async #notifyError(request, signal, startedAt, error) {
1650
+ let headers;
1651
+ try {
1652
+ headers = new Headers({
1653
+ ...request.layerHeaders,
1654
+ ...request.headers
1655
+ });
1656
+ } catch {
1657
+ headers = new Headers();
1658
+ }
1659
+ const notification = dispatchRequestHook(this.#hooks, "onError", {
1660
+ operationId: request.operationId,
1661
+ signal,
1662
+ method: request.method.toUpperCase(),
1663
+ path: request.path,
1664
+ url: joinUrl(request.baseUrl ?? this.#baseUrl, request.path) + buildQuery(request.query),
1665
+ headers,
1666
+ attempt: currentTransportAttempt(request) || 1,
1667
+ duration: this.#clock.now() - startedAt,
1668
+ error
1669
+ });
1670
+ try {
1671
+ await waitForRequest(notification, signal);
1672
+ } catch (hookError) {
1673
+ if (!signal.aborted) throw hookError;
1674
+ }
1675
+ }
1607
1676
  };
1608
1677
  //#endregion
1609
1678
  //#region src/core/operation.ts
1679
+ /** HTTP-метод операции. */
1680
+ const OperationMethod = Object.freeze({
1681
+ Get: "GET",
1682
+ Post: "POST",
1683
+ Put: "PUT",
1684
+ Patch: "PATCH",
1685
+ Delete: "DELETE"
1686
+ });
1610
1687
  /** Семантическая безопасность автоматического повтора операции. */
1611
1688
  const RetrySafety = Object.freeze({
1612
1689
  /** Автоматический повтор не создаёт неприемлемого эффекта; обычно это чтение. */
@@ -1676,7 +1753,7 @@ function isRequestBodyReplayable(request) {
1676
1753
  /** Разрешает retrySafety запроса из явного override, каталога либо raw fallback. */
1677
1754
  function resolveRetryPolicy(request, catalog) {
1678
1755
  let retrySafety;
1679
- const known = catalog.retrySafetyOf(request.operationId);
1756
+ const known = catalog.definitionOf(request.operationId)?.retrySafety;
1680
1757
  if (request.retrySafety !== void 0) retrySafety = request.retrySafety;
1681
1758
  else if (known !== void 0) retrySafety = known;
1682
1759
  else if (request.operationId === "raw" && SAFE_RAW_METHODS.has(request.method.toUpperCase())) retrySafety = RetrySafety.Safe;
@@ -1690,15 +1767,22 @@ function resolveRetryPolicy(request, catalog) {
1690
1767
  };
1691
1768
  }
1692
1769
  /**
1770
+ * Ошибка, после которой повтор невозможен в принципе: отмена либо истёкший общий срок операции.
1771
+ * Таймаут отдельной попытки сюда не входит — он повторяется как сетевой сбой.
1772
+ */
1773
+ function isTerminal(error) {
1774
+ return error instanceof ItdAbortError || error instanceof ItdTimeoutError && error.budget === TimeoutBudget.Deadline;
1775
+ }
1776
+ /**
1693
1777
  * Стоит ли повторять запрос после этой ошибки.
1694
1778
  *
1695
1779
  * `429` гарантирует, что операция не была обработана, поэтому допускает даже unsafe retry.
1696
- * Обрыв сети, timeout и `5xx` такой гарантии не дают и требуют safe/idempotent операции.
1780
+ * Обрыв сети, таймаут попытки и `5xx` такой гарантии не дают и требуют safe/idempotent операции.
1697
1781
  * Ошибка подготовки файла возникает до обращения к серверу и потому зависит только от
1698
1782
  * собственного признака retryable и повторяемости тела.
1699
1783
  */
1700
1784
  function isRetryable(error, policy) {
1701
- if (error instanceof ItdAbortError || !policy.bodyReplayable) return false;
1785
+ if (isTerminal(error) || !policy.bodyReplayable) return false;
1702
1786
  const repeatableOperation = policy.retrySafety === RetrySafety.Safe || policy.retrySafety === RetrySafety.Idempotent;
1703
1787
  if (error instanceof ItdApiError) {
1704
1788
  if (error.status === 429) return true;
@@ -1726,7 +1810,7 @@ function backoffDelay(retryAttempt, options, random) {
1726
1810
  function createRetryScheduler(options, random = Math.random) {
1727
1811
  return (error, retryAttempt, policy) => {
1728
1812
  if (retryAttempt >= options.attempts) return void 0;
1729
- if (error instanceof ItdAbortError || !policy.bodyReplayable) return void 0;
1813
+ if (isTerminal(error) || !policy.bodyReplayable) return void 0;
1730
1814
  if (options.shouldRetry) return options.shouldRetry(error, retryAttempt, policy) ? backoffDelay(retryAttempt, options, random) : void 0;
1731
1815
  if (!isRetryable(error, policy)) return void 0;
1732
1816
  if (error instanceof ItdApiError && error.retryAfter !== void 0) return error.retryAfter > options.maxDelay ? void 0 : error.retryAfter;
@@ -1735,6 +1819,8 @@ function createRetryScheduler(options, random = Math.random) {
1735
1819
  }
1736
1820
  //#endregion
1737
1821
  //#region src/core/execution/middleware.ts
1822
+ /** Сигнал для запроса без lifecycle: никогда не срабатывает. */
1823
+ const IDLE_SIGNAL = new AbortController().signal;
1738
1824
  /** Ожидание повтора, которое уважает отмену запроса. */
1739
1825
  function sleep(clock, ms, signal) {
1740
1826
  if (!signal) return new Promise((resolve) => clock.schedule(resolve, ms));
@@ -1752,6 +1838,39 @@ function sleep(clock, ms, signal) {
1752
1838
  });
1753
1839
  }
1754
1840
  /**
1841
+ * Слой плагинов: обёртки логической операции.
1842
+ *
1843
+ * Transformer видит запрос и результат по одному разу, независимо от повторов и обновления
1844
+ * авторизации, и получает запрос с пользовательским `signal`; слои ниже — с общим сигналом
1845
+ * операции. Служебное состояние операции переносится на запрос, собранный обёрткой заново.
1846
+ * Здесь же к операции привязывается снимок attempt interceptors; сами они выполняются
1847
+ * транспортом на каждой попытке.
1848
+ */
1849
+ function createPluginsMiddleware(plugins) {
1850
+ return (request, next) => {
1851
+ const lifecycle = lifecycleSignalOf(request) ?? request.signal;
1852
+ return plugins.run(request, (prepared) => {
1853
+ return next(withOperationState({
1854
+ ...prepared,
1855
+ ...lifecycle ? { signal: lifecycle } : {}
1856
+ }, request));
1857
+ }, lifecycle ?? IDLE_SIGNAL);
1858
+ };
1859
+ }
1860
+ /**
1861
+ * Слой контракта операции: превращает разобранное тело ответа в публичный результат.
1862
+ *
1863
+ * Обёртки операции получают готовый результат метода, а не форму ответа сервера. У
1864
+ * `raw`-запроса функции чтения нет, тело возвращается как есть.
1865
+ */
1866
+ function createDecodeMiddleware() {
1867
+ return async (request, next) => {
1868
+ const body = await next(request);
1869
+ const read = operationReaderOf(request);
1870
+ return read ? read(body, request) : body;
1871
+ };
1872
+ }
1873
+ /**
1755
1874
  * Слой очереди: ограничение конкурентности и частоты.
1756
1875
  *
1757
1876
  * Должен стоять непосредственно вокруг одной транспортной попытки: тогда ожидание retry
@@ -1763,13 +1882,6 @@ function createQueueMiddleware(schedule) {
1763
1882
  return (request, next) => request.skipQueue ? next(request) : schedule(request, () => next(request));
1764
1883
  }
1765
1884
  /**
1766
- * Слой плагинов.
1767
- *
1768
- * Стоит снаружи повторов и очереди: operation transformers видят запрос и ответ по одному
1769
- * разу, иначе, например, текст поста зашифруется дважды. Здесь же к операции привязывается
1770
- * snapshot attempt interceptors; сами они выполняются транспортом на каждой попытке.
1771
- */
1772
- /**
1773
1885
  * Слой сервисов.
1774
1886
  *
1775
1887
  * Запросу с полем `service` подставляет хост сервиса, его заголовки и `skipAuth`, если
@@ -1807,15 +1919,16 @@ function createServicesMiddleware(registry) {
1807
1919
  }
1808
1920
  /** Однократная auth-подготовка вне retry исходного запроса. */
1809
1921
  function createAuthPreflightMiddleware(getAuth) {
1810
- return async (request, next) => {
1811
- if (!request.skipAuth) {
1812
- const auth = getAuth();
1813
- if (auth.preflight) {
1814
- const pending = auth.preflight(request.skipAuthRefresh !== true);
1815
- await (request.signal ? waitForRequest(pending, request.signal) : pending);
1816
- }
1817
- }
1818
- return next(request);
1922
+ return (request, next) => {
1923
+ if (request.skipAuth) return next(request);
1924
+ const auth = getAuth();
1925
+ const preflight = auth.preflight;
1926
+ if (!preflight) return next(request);
1927
+ return (async () => {
1928
+ const pending = preflight.call(auth, request.skipAuthRefresh !== true);
1929
+ await (request.signal ? waitForRequest(pending, request.signal) : pending);
1930
+ return next(request);
1931
+ })();
1819
1932
  };
1820
1933
  }
1821
1934
  /**
@@ -1824,18 +1937,22 @@ function createAuthPreflightMiddleware(getAuth) {
1824
1937
  * Загрузка storage, внешний `getToken` и ленивый sign-in могут быть асинхронными; sign-in
1825
1938
  * сам входит в ту же queue. Поэтому эти действия обязаны завершиться до захвата её слота.
1826
1939
  */
1827
- function createAuthPreparationMiddleware(auth) {
1828
- return async (request, next) => {
1829
- if (!request.skipAuth) {
1830
- const pending = auth.prepare();
1940
+ function createAuthPreparationMiddleware(getAuth) {
1941
+ return (request, next) => {
1942
+ if (request.skipAuth) return next(request);
1943
+ const auth = getAuth();
1944
+ const prepare = auth.prepare;
1945
+ if (!prepare) return next(request);
1946
+ return (async () => {
1831
1947
  try {
1948
+ const pending = prepare.call(auth);
1832
1949
  await (request.signal ? waitForRequest(pending, request.signal) : pending);
1833
1950
  } catch (error) {
1834
1951
  requestAuthRecoveryState(request).preparationErrors.add(error);
1835
1952
  throw error;
1836
1953
  }
1837
- }
1838
- return next(request);
1954
+ return next(request);
1955
+ })();
1839
1956
  };
1840
1957
  }
1841
1958
  /**
@@ -1869,10 +1986,10 @@ function createAttemptMiddleware() {
1869
1986
  */
1870
1987
  function createAuthRecoveryMiddleware(auth) {
1871
1988
  return async (request, next) => {
1872
- const recovery = requestAuthRecoveryState(request);
1873
1989
  try {
1874
1990
  return await next(request);
1875
1991
  } catch (error) {
1992
+ const recovery = requestAuthRecoveryState(request);
1876
1993
  const preparationFailed = recovery.preparationErrors.delete(error);
1877
1994
  if (request.skipAuthRefresh || recovery.recovered || preparationFailed || !isItdApiError(error) || error.status !== 401) throw error;
1878
1995
  const pending = auth.recover();
@@ -1901,55 +2018,62 @@ function resolveBackoff(retry, global) {
1901
2018
  * Слой повторов.
1902
2019
  *
1903
2020
  * Ответ `429` обрабатывается отдельно от прочих ошибок лестницей пауз и с придержанием
1904
- * всей очереди; сетевые сбои и `5xx` — экспоненциальным откатом. Настройка `retry`
1905
- * у отдельного запроса имеет приоритет над глобальной.
2021
+ * всей очереди; сетевые сбои, таймаут попытки и `5xx` — экспоненциальным откатом.
2022
+ * Настройка `retry` у отдельного запроса имеет приоритет над глобальной.
1906
2023
  */
1907
2024
  function createRetryMiddleware(deps) {
1908
2025
  const globalScheduler = deps.retry ? createRetryScheduler(deps.retry) : void 0;
1909
- const nextDelay = (error, retryAttempt, rateLimitAttempt, request, policy, backoff) => {
1910
- if (isItdRateLimitError(error)) {
1911
- if (!policy.bodyReplayable) return void 0;
1912
- const wait = error.retryAfter ?? deps.rateLimitDelays[rateLimitAttempt - 1];
1913
- if (wait === void 0) return void 0;
1914
- deps.pauseQueue?.(wait, request);
1915
- deps.logger?.debug(`лимит частоты, повтор ${rateLimitAttempt} через ${wait} мс`);
1916
- return wait;
1917
- }
1918
- return backoff?.(error, retryAttempt, policy);
2026
+ const ladder = deps.rateLimitDelays;
2027
+ const ladderCeiling = ladder && ladder.length > 0 ? Math.max(...ladder) : 0;
2028
+ /** Пауза по лестнице `429`; `undefined` — лестница пройдена или пауза сервера слишком велика. */
2029
+ const ladderDelay = (error, rateLimitAttempt, request, policy) => {
2030
+ if (!ladder || !policy.bodyReplayable || rateLimitAttempt > ladder.length) return void 0;
2031
+ if (error.retryAfter !== void 0 && error.retryAfter > ladderCeiling) return void 0;
2032
+ const wait = error.retryAfter ?? ladder[rateLimitAttempt - 1];
2033
+ if (wait === void 0) return void 0;
2034
+ deps.pauseQueue?.(wait, request);
2035
+ deps.logger?.info(`лимит частоты ${request.method.toUpperCase()} ${request.path}, повтор ${rateLimitAttempt} через ${wait} мс`);
2036
+ return wait;
1919
2037
  };
1920
- return async (request, next) => {
1921
- const trackedRequest = trackRequestAttempts(request);
2038
+ return (request, next) => {
2039
+ const backoff = resolveBackoff(request.retry, globalScheduler);
2040
+ if (!backoff && (!ladder || ladder.length === 0)) return next(request);
1922
2041
  const method = request.method.toUpperCase();
1923
2042
  const policy = resolveRetryPolicy(request, deps.catalog);
1924
- const backoff = resolveBackoff(request.retry, globalScheduler);
1925
- let retryAttempt = 0;
1926
- let rateLimitAttempt = 0;
1927
- for (;;) try {
1928
- return await next(trackedRequest);
1929
- } catch (error) {
1930
- const transportAttempt = currentTransportAttempt(trackedRequest);
1931
- if (isItdRateLimitError(error)) rateLimitAttempt += 1;
1932
- else retryAttempt += 1;
1933
- const delay = nextDelay(error, retryAttempt, rateLimitAttempt, trackedRequest, policy, backoff);
1934
- if (delay === void 0) throw error;
1935
- const notification = dispatchRequestHook(deps.hooks, "onRetry", {
1936
- operationId: request.operationId,
1937
- signal: request.signal,
1938
- method,
1939
- path: request.path,
1940
- url: deps.buildUrl(request),
1941
- headers: new Headers({
1942
- ...request.layerHeaders,
1943
- ...request.headers
1944
- }),
1945
- attempt: transportAttempt,
1946
- error,
1947
- delay
1948
- });
1949
- await (request.signal ? waitForRequest(notification, request.signal) : notification);
1950
- deps.logger?.debug(`повтор ${method} ${request.path}, попытка ${transportAttempt + 1} через ${delay} мс`);
1951
- await sleep(deps.clock ?? systemClock, delay, request.signal);
1952
- }
2043
+ return (async () => {
2044
+ let retryAttempt = 0;
2045
+ let rateLimitAttempt = 0;
2046
+ for (;;) try {
2047
+ return await next(request);
2048
+ } catch (error) {
2049
+ if (request.signal?.aborted) throw error;
2050
+ const transportAttempt = currentTransportAttempt(request);
2051
+ const rateLimited = ladder !== void 0 && isItdRateLimitError(error);
2052
+ if (rateLimited) rateLimitAttempt += 1;
2053
+ else retryAttempt += 1;
2054
+ const delay = rateLimited ? ladderDelay(error, rateLimitAttempt, request, policy) : backoff?.(error, retryAttempt, policy);
2055
+ if (delay === void 0) throw error;
2056
+ if (hasRequestHook(deps.hooks, "onRetry")) {
2057
+ const notification = dispatchRequestHook(deps.hooks, "onRetry", {
2058
+ operationId: request.operationId,
2059
+ signal: request.signal,
2060
+ method,
2061
+ path: request.path,
2062
+ url: deps.buildUrl(request),
2063
+ headers: new Headers({
2064
+ ...request.layerHeaders,
2065
+ ...request.headers
2066
+ }),
2067
+ attempt: transportAttempt,
2068
+ error,
2069
+ delay
2070
+ });
2071
+ await (request.signal ? waitForRequest(notification, request.signal) : notification);
2072
+ }
2073
+ deps.logger?.info(`повтор ${method} ${request.path}, попытка ${transportAttempt + 1} через ${delay} мс`);
2074
+ await sleep(deps.clock ?? systemClock, delay, request.signal);
2075
+ }
2076
+ })();
1953
2077
  };
1954
2078
  }
1955
2079
  //#endregion
@@ -1979,15 +2103,6 @@ function unwrapData(body) {
1979
2103
  if (keys.length !== 1 || keys[0] !== "data") return body;
1980
2104
  return body.data;
1981
2105
  }
1982
- /**
1983
- * Обычный объект — не `null`, не массив.
1984
- *
1985
- * Живёт здесь, а не в каждом разборщике ответа: одна и та же проверка нужна и фабрике
1986
- * ошибок, и приведению уведомлений.
1987
- */
1988
- function isRecord(value) {
1989
- return typeof value === "object" && value !== null && !Array.isArray(value);
1990
- }
1991
2106
  /** Непустая строка либо `undefined`. Отличается от {@link pickString} тем, что берёт значение, а не поле. */
1992
2107
  function asString(value) {
1993
2108
  return typeof value === "string" && value.length > 0 ? value : void 0;
@@ -2031,6 +2146,13 @@ function pickString(source, field) {
2031
2146
  //#endregion
2032
2147
  //#region src/core/error-factory.ts
2033
2148
  /**
2149
+ * Отличает строковый машинный код (`NOT_FOUND`) от обычного текста и HTML страницы прокси.
2150
+ * Ограничение длины не даёт случайно принять большое служебное тело за код ошибки.
2151
+ */
2152
+ function readPlainTextErrorCode(value) {
2153
+ return value.length <= 128 && /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/.test(value) ? value : void 0;
2154
+ }
2155
+ /**
2034
2156
  * Сводит ошибки по полям к единой форме `{ поле: [сообщения] }`.
2035
2157
  *
2036
2158
  * Понимает две документированные формы:
@@ -2070,14 +2192,17 @@ function collectFieldErrors(source) {
2070
2192
  */
2071
2193
  function parseErrorBody(body, status, statusText = "") {
2072
2194
  const fallbackMessage = statusText ? `HTTP ${status} ${statusText}` : `HTTP ${status}`;
2073
- if (typeof body === "string") return {
2074
- code: "UNKNOWN_ERROR",
2075
- message: asString(body.trim()) ?? fallbackMessage,
2076
- detail: void 0,
2077
- title: void 0,
2078
- fieldErrors: {},
2079
- userId: void 0
2080
- };
2195
+ if (typeof body === "string") {
2196
+ const message = asString(body.trim()) ?? fallbackMessage;
2197
+ return {
2198
+ code: readPlainTextErrorCode(message) ?? "UNKNOWN_ERROR",
2199
+ message,
2200
+ detail: void 0,
2201
+ title: void 0,
2202
+ fieldErrors: {},
2203
+ userId: void 0
2204
+ };
2205
+ }
2081
2206
  if (!isRecord(body)) return {
2082
2207
  code: "UNKNOWN_ERROR",
2083
2208
  message: fallbackMessage,
@@ -2271,33 +2396,15 @@ async function readBody(response) {
2271
2396
  const text = await response.text();
2272
2397
  return text === "" ? void 0 : text;
2273
2398
  }
2274
- /** Ошибка отмены в формате `fetch`. */
2275
- function createAbortError() {
2276
- const error = /* @__PURE__ */ new Error("Операция прервана");
2277
- error.name = "AbortError";
2278
- return error;
2279
- }
2280
- /** Прерывает ожидание промиса при срабатывании сигнала. */
2281
- function abortable(promise, signal) {
2282
- if (signal.aborted) {
2283
- promise.catch(() => {});
2284
- return Promise.reject(createAbortError());
2285
- }
2286
- let onAbort;
2287
- const interrupted = new Promise((_resolve, reject) => {
2288
- onAbort = () => reject(createAbortError());
2289
- signal.addEventListener("abort", onAbort, { once: true });
2290
- });
2291
- return Promise.race([promise, interrupted]).finally(() => {
2292
- if (onAbort) signal.removeEventListener("abort", onAbort);
2293
- });
2399
+ function describe(error) {
2400
+ return error instanceof Error ? error.message : String(error);
2294
2401
  }
2295
2402
  /**
2296
2403
  * Единственное место, откуда библиотека ходит в сеть.
2297
2404
  *
2298
- * Отвечает за сборку URL, заголовки, cookie, таймауты, разбор ответа и превращение любой
2299
- * неудачи в типизированную ошибку. Авторизация, повторы, очередь и плагины — отдельные
2300
- * слои конвейера, и транспорт о них не знает.
2405
+ * Отвечает за сборку URL, заголовки, cookie, срок одной попытки, разбор ответа и превращение
2406
+ * любой неудачи в типизированную ошибку. Авторизация, повторы, очередь и плагины — отдельные
2407
+ * стадии конвейера, и транспорт о них не знает.
2301
2408
  */
2302
2409
  var Transport = class {
2303
2410
  #config;
@@ -2306,141 +2413,114 @@ var Transport = class {
2306
2413
  this.#config = config;
2307
2414
  this.#deps = deps;
2308
2415
  }
2309
- /** Базовый URL, к которому обращается транспорт. */
2310
- get baseUrl() {
2311
- return this.#config.baseUrl;
2312
- }
2313
2416
  /**
2314
2417
  * Выполняет один сетевой запрос.
2315
2418
  *
2316
2419
  * @throws {ItdApiError} если сервер ответил статусом ≥ 400
2317
- * @throws {ItdTimeoutError} если истёк таймаут
2420
+ * @throws {ItdTimeoutError} если истёк срок попытки
2318
2421
  * @throws {ItdAbortError} если запрос отменён через `signal`
2319
2422
  * @throws {ItdNetworkError} если запрос не дошёл до сервера
2320
2423
  */
2321
2424
  send = async (input) => {
2322
2425
  const request = identifyRequest(input);
2323
- const method = request.method.toUpperCase();
2324
- const url = this.buildUrl(request);
2325
- const attempt = request.attempt ?? 1;
2326
2426
  const timeout = request.timeout ?? this.#config.timeout;
2327
- const abort = createRequestAbortScope(request.signal, this.#deps.lifetimeSignal, timeout, this.#config.clock);
2328
- const startedAt = this.#config.clock.now();
2427
+ const method = request.method.toUpperCase();
2428
+ const attempt = {
2429
+ request,
2430
+ method,
2431
+ url: this.buildUrl(request),
2432
+ abort: createRequestAbortScope(request.signal, void 0, this.#config.clock, {
2433
+ after: timeout,
2434
+ error: () => new ItdTimeoutError({
2435
+ timeout,
2436
+ method,
2437
+ path: request.path,
2438
+ budget: TimeoutBudget.Attempt
2439
+ })
2440
+ }),
2441
+ startedAt: this.#config.clock.now(),
2442
+ number: request.attempt ?? 1
2443
+ };
2444
+ const { signal } = attempt.abort;
2329
2445
  let cleanupBody;
2330
2446
  try {
2331
- const headers = await abortable(this.#buildHeaders(request, url), abort.signal).catch((error) => {
2332
- throw this.#toTransportError(error, abort, request, method, timeout);
2333
- });
2447
+ let headers;
2448
+ try {
2449
+ headers = await waitForRequest(this.#buildHeaders(request, attempt.url), signal);
2450
+ } catch (error) {
2451
+ throw this.#toTransportError(attempt, error);
2452
+ }
2334
2453
  let body;
2335
2454
  try {
2336
- const prepared = await this.#prepareBody(request, headers, abort.signal, attempt);
2455
+ const prepared = await this.#prepareBody(attempt, headers);
2337
2456
  body = prepared.body;
2338
2457
  cleanupBody = prepared.cleanup;
2339
2458
  } catch (error) {
2340
- const failure = abort.signal.aborted || error instanceof ItdError ? this.#toTransportError(error, abort, request, method, timeout) : new ItdConfigError(`Не удалось подготовить тело ${method} ${request.path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2341
- const context = {
2342
- operationId: request.operationId,
2343
- signal: abort.signal,
2344
- method,
2345
- path: request.path,
2346
- url,
2347
- headers,
2348
- attempt
2349
- };
2350
- await this.#dispatchErrorHook(request, {
2351
- ...context,
2352
- duration: this.#config.clock.now() - startedAt,
2353
- error: failure
2354
- }, abort, timeout);
2459
+ const failure = signal.aborted || error instanceof ItdError ? this.#toTransportError(attempt, error) : new ItdConfigError(`Не удалось подготовить тело ${attempt.method} ${request.path}: ${describe(error)}`, { cause: error });
2460
+ await this.#report(attempt, headers, failure);
2355
2461
  throw failure;
2356
2462
  }
2357
- const context = {
2358
- operationId: request.operationId,
2359
- signal: abort.signal,
2360
- method,
2361
- path: request.path,
2362
- url,
2363
- headers,
2364
- attempt
2365
- };
2366
- try {
2367
- await abortable(dispatchRequestHook(this.#config.hooks, "onRequest", context), abort.signal);
2463
+ let context;
2464
+ const contextOf = () => context ??= this.#context(attempt, headers);
2465
+ if (hasRequestHook(this.#config.hooks, "onRequest")) try {
2466
+ await waitForRequest(dispatchRequestHook(this.#config.hooks, "onRequest", { ...contextOf() }), signal);
2368
2467
  } catch (error) {
2369
- const failure = abort.signal.aborted ? this.#toTransportError(error, abort, request, method, timeout) : error;
2370
- await this.#dispatchErrorHook(request, {
2371
- ...context,
2372
- duration: this.#config.clock.now() - startedAt,
2373
- error: failure
2374
- }, abort, timeout);
2468
+ const failure = this.#abortedOr(attempt, error);
2469
+ await this.#report(attempt, headers, failure);
2375
2470
  throw failure;
2376
2471
  }
2377
- this.#config.logger?.debug(`→ ${method} ${request.path}`, {
2472
+ const logger = this.#config.logger;
2473
+ if (logger && isLevelEnabled(logger, LogLevel.Debug)) logger.debug(`→ ${attempt.method} ${request.path}`, {
2378
2474
  headers: redactHeaders(headers),
2379
2475
  body: request.bodyFactory ? "[повторяемое тело]" : redactBody(request.body)
2380
2476
  });
2381
2477
  let response;
2382
2478
  try {
2383
- const init = {
2384
- method,
2385
- headers,
2386
- signal: abort.signal,
2387
- ...body !== void 0 ? { body } : {},
2388
- ...this.#config.sendCredentials ? { credentials: "include" } : {}
2389
- };
2390
- if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) init.duplex = "half";
2391
- response = await runAttemptInterceptors(request, {
2392
- ...context,
2393
- body,
2394
- signal: abort.signal
2395
- }, async () => {
2396
- try {
2397
- return await this.#config.fetch(url, init);
2398
- } catch (error) {
2399
- throw this.#toTransportError(error, abort, request, method, timeout);
2400
- }
2401
- });
2479
+ response = await waitForRequest(this.#fetch(attempt, headers, body, contextOf), signal);
2402
2480
  } catch (error) {
2403
- const duration = this.#config.clock.now() - startedAt;
2404
- await this.#dispatchErrorHook(request, {
2405
- ...context,
2406
- duration,
2407
- error
2408
- }, abort, timeout);
2409
- this.#config.logger?.warn(`× ${method} ${request.path} (${duration} мс): ${error instanceof Error ? error.message : String(error)}`);
2410
- throw error;
2481
+ const failure = this.#abortedOr(attempt, error);
2482
+ await this.#report(attempt, headers, failure);
2483
+ this.#config.logger?.warn(`× ${attempt.method} ${request.path} (${this.#elapsed(attempt)} мс): ${describe(failure)}`);
2484
+ throw failure;
2411
2485
  }
2412
2486
  if (this.#deps.onRateLimit) {
2413
2487
  const { limit, remaining } = readRateLimit(response.headers);
2414
2488
  this.#deps.onRateLimit(limit, remaining, request);
2415
2489
  }
2416
- if (this.#config.useCookieJar) (request.cookieJar ?? this.#deps.cookies)?.setFromResponse(response.url || url, response);
2490
+ if (this.#config.useCookieJar) (request.cookieJar ?? this.#deps.cookies)?.setFromResponse(response.url || attempt.url, response);
2417
2491
  if (response.ok && hasRequestHook(this.#config.hooks, "onResponse")) {
2418
2492
  const hookResponse = response.clone();
2419
2493
  try {
2420
- await abortable(dispatchRequestHook(this.#config.hooks, "onResponse", {
2421
- ...context,
2494
+ await waitForRequest(dispatchRequestHook(this.#config.hooks, "onResponse", {
2495
+ ...contextOf(),
2422
2496
  status: response.status,
2423
- duration: this.#config.clock.now() - startedAt,
2497
+ duration: this.#elapsed(attempt),
2424
2498
  response: hookResponse
2425
- }), abort.signal);
2499
+ }), signal);
2426
2500
  } catch (error) {
2427
2501
  response.body?.cancel().catch(() => {});
2428
- const failure = abort.signal.aborted ? this.#toTransportError(error, abort, request, method, timeout) : error;
2429
- await this.#dispatchErrorHook(request, {
2430
- ...context,
2431
- duration: this.#config.clock.now() - startedAt,
2432
- error: failure
2433
- }, abort, timeout);
2502
+ const failure = this.#abortedOr(attempt, error);
2503
+ await this.#report(attempt, headers, failure);
2434
2504
  throw failure;
2435
2505
  } finally {
2436
2506
  if (!hookResponse.bodyUsed) hookResponse.body?.cancel().catch(() => {});
2437
2507
  }
2438
2508
  }
2439
- const payload = await this.#readBodyOrFail(response, context, request, method, abort, timeout, startedAt);
2440
- const duration = this.#config.clock.now() - startedAt;
2509
+ let payload;
2510
+ try {
2511
+ payload = await waitForRequest(readBody(response), signal);
2512
+ attempt.abort.disarm();
2513
+ } catch (error) {
2514
+ await response.body?.cancel().catch(() => {});
2515
+ const failure = this.#toTransportError(attempt, error);
2516
+ await this.#report(attempt, headers, failure);
2517
+ this.#config.logger?.warn(`× ${attempt.method} ${request.path}: не удалось прочитать тело ответа — ${failure.message}`);
2518
+ throw failure;
2519
+ }
2520
+ const duration = this.#elapsed(attempt);
2441
2521
  if (!response.ok) {
2442
2522
  const error = createApiError({
2443
- method,
2523
+ method: attempt.method,
2444
2524
  path: request.path,
2445
2525
  status: response.status,
2446
2526
  now: this.#config.clock.now(),
@@ -2449,58 +2529,96 @@ var Transport = class {
2449
2529
  response,
2450
2530
  body: payload
2451
2531
  });
2452
- await this.#dispatchErrorHook(request, {
2453
- ...context,
2454
- duration,
2455
- error
2456
- }, abort, timeout);
2457
- this.#config.logger?.warn(`← ${response.status} ${method} ${request.path} (${duration} мс): ${error.message}`);
2532
+ await this.#report(attempt, headers, error);
2533
+ this.#config.logger?.warn(`← ${response.status} ${attempt.method} ${request.path} (${duration} мс): ${error.message}`);
2458
2534
  throw error;
2459
2535
  }
2460
- this.#config.logger?.debug(`← ${response.status} ${method} ${request.path} (${duration} мс)`);
2536
+ this.#config.logger?.debug(`← ${response.status} ${attempt.method} ${request.path} (${duration} мс)`);
2461
2537
  return request.raw ? payload : unwrapData(payload);
2462
2538
  } finally {
2463
2539
  try {
2464
2540
  await cleanupBody?.();
2465
2541
  } catch (error) {
2466
- this.#config.logger?.warn(`не удалось закрыть тело ${method} ${request.path}`, error);
2542
+ this.#config.logger?.warn(`не удалось закрыть тело ${attempt.method} ${request.path}`, error);
2467
2543
  } finally {
2468
- abort.cleanup();
2544
+ attempt.abort.cleanup();
2469
2545
  }
2470
2546
  }
2471
2547
  };
2472
- async #dispatchErrorHook(request, context, abort, timeout) {
2473
- markRequestErrorObserved(request, context.error);
2474
- const onAbort = () => markRequestErrorNotificationAborted(request);
2475
- if (abort.signal.aborted) onAbort();
2476
- else abort.signal.addEventListener("abort", onAbort, { once: true });
2477
- try {
2478
- await abortable(dispatchRequestHook(this.#config.hooks, "onError", context), abort.signal);
2479
- } catch (error) {
2480
- const failure = abort.signal.aborted ? this.#toTransportError(error, abort, request, context.method, timeout) : error;
2481
- markRequestErrorObserved(request, failure);
2482
- throw failure;
2483
- } finally {
2484
- abort.signal.removeEventListener("abort", onAbort);
2548
+ /**
2549
+ * Итоговый URL со строкой запроса. Нужен и слою повторов — для хука `onRetry`.
2550
+ *
2551
+ * Хост берётся из самого запроса, если он там задан: у сервисов платформы свои домены.
2552
+ */
2553
+ buildUrl(request) {
2554
+ const base = request.baseUrl ?? this.#config.baseUrl;
2555
+ return joinUrl(base, request.path) + buildQuery(request.query);
2556
+ }
2557
+ /**
2558
+ * Собирает общие заголовки клиента: `User-Agent`, идентификатор устройства,
2559
+ * заголовки конфигурации и cookie для указанного адреса.
2560
+ */
2561
+ async platformHeaders(url, cookieJar = this.#deps.cookies) {
2562
+ const headers = new Headers();
2563
+ headers.set("X-Requested-With", "XMLHttpRequest");
2564
+ if (this.#config.userAgent) setHeader(headers, "User-Agent", this.#config.userAgent);
2565
+ if (this.#deps.getDeviceId) setHeader(headers, "X-Device-Id", await this.#deps.getDeviceId());
2566
+ for (const [name, value] of Object.entries(this.#config.headers)) setHeader(headers, name, value);
2567
+ if (this.#config.useCookieJar && cookieJar) {
2568
+ const cookie = cookieJar.getHeader(url);
2569
+ if (cookie) setHeader(headers, "Cookie", cookie);
2485
2570
  }
2571
+ return headers;
2572
+ }
2573
+ /** Данные попытки для хуков `onRequest`, `onResponse` и перехватчиков. */
2574
+ #context(attempt, headers) {
2575
+ return {
2576
+ operationId: attempt.request.operationId,
2577
+ signal: attempt.abort.signal,
2578
+ method: attempt.method,
2579
+ path: attempt.request.path,
2580
+ url: attempt.url,
2581
+ headers,
2582
+ attempt: attempt.number
2583
+ };
2584
+ }
2585
+ #elapsed(attempt) {
2586
+ return this.#config.clock.now() - attempt.startedAt;
2587
+ }
2588
+ /** Ошибка хука: отмена во время его работы становится транспортной ошибкой, остальное — как есть. */
2589
+ #abortedOr(attempt, error) {
2590
+ return attempt.abort.signal.aborted ? this.#toTransportError(attempt, error) : error;
2591
+ }
2592
+ /**
2593
+ * Дополняет общие заголовки значением `Accept`, заголовками конвейера и вызова.
2594
+ * Заголовки вызова применяются последними.
2595
+ */
2596
+ async #buildHeaders(request, url) {
2597
+ const headers = await this.platformHeaders(url, request.cookieJar);
2598
+ if (!headers.has("Accept")) headers.set("Accept", "application/json");
2599
+ for (const [name, value] of Object.entries(request.layerHeaders ?? {})) setHeader(headers, name, value);
2600
+ for (const [name, value] of Object.entries(request.headers ?? {})) setHeader(headers, name, value);
2601
+ return headers;
2486
2602
  }
2487
2603
  /** Подготавливает тело внутри попытки, чтобы поток можно было открыть заново при retry. */
2488
- async #prepareBody(request, headers, signal, attempt) {
2604
+ async #prepareBody(attempt, headers) {
2605
+ const { request } = attempt;
2606
+ const { signal } = attempt.abort;
2489
2607
  if (request.bodyFactory) {
2490
2608
  if (request.body !== void 0 && request.body !== null) throw new ItdConfigError("body и bodyFactory нельзя задавать одновременно");
2491
2609
  const pending = Promise.resolve(request.bodyFactory({
2492
2610
  signal,
2493
- attempt
2611
+ attempt: attempt.number
2494
2612
  }));
2495
2613
  let prepared;
2496
2614
  try {
2497
- prepared = await abortable(pending, signal);
2615
+ prepared = await waitForRequest(pending, signal);
2498
2616
  } catch (error) {
2499
2617
  if (signal.aborted) pending.then(async (late) => {
2500
2618
  try {
2501
2619
  await late.cleanup?.();
2502
2620
  } catch (cleanupError) {
2503
- this.#config.logger?.warn(`не удалось закрыть отложенное тело ${request.method} ${request.path}`, cleanupError);
2621
+ this.#config.logger?.warn(`не удалось закрыть отложенное тело ${attempt.method} ${request.path}`, cleanupError);
2504
2622
  }
2505
2623
  }).catch(() => {});
2506
2624
  throw error;
@@ -2525,72 +2643,76 @@ var Transport = class {
2525
2643
  cleanup: void 0
2526
2644
  };
2527
2645
  }
2528
- /** Читает тело и преобразует ошибку чтения в транспортную ошибку библиотеки. */
2529
- async #readBodyOrFail(response, context, request, method, abort, timeout, startedAt) {
2530
- try {
2531
- return await abortable(readBody(response), abort.signal);
2532
- } catch (error) {
2533
- await response.body?.cancel().catch(() => {});
2534
- const failure = this.#toTransportError(error, abort, request, method, timeout);
2535
- await this.#dispatchErrorHook(request, {
2536
- ...context,
2537
- duration: this.#config.clock.now() - startedAt,
2538
- error: failure
2539
- }, abort, timeout);
2540
- this.#config.logger?.warn(`× ${method} ${request.path}: не удалось прочитать тело ответа — ${failure.message}`);
2541
- throw failure;
2542
- }
2646
+ /** Вызывает `fetch` через перехватчики попытки; сбой сети становится ошибкой библиотеки. */
2647
+ #fetch(attempt, headers, body, contextOf) {
2648
+ const init = {
2649
+ method: attempt.method,
2650
+ headers,
2651
+ signal: attempt.abort.signal,
2652
+ ...body !== void 0 ? { body } : {},
2653
+ ...this.#config.sendCredentials ? { credentials: "include" } : {}
2654
+ };
2655
+ if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) init.duplex = "half";
2656
+ const execute = async () => {
2657
+ try {
2658
+ return await this.#config.fetch(attempt.url, init);
2659
+ } catch (error) {
2660
+ throw this.#toTransportError(attempt, error);
2661
+ }
2662
+ };
2663
+ const interceptors = attemptInterceptorScope(attempt.request);
2664
+ if (!interceptors) return execute();
2665
+ return runAttemptInterceptors(interceptors, {
2666
+ ...contextOf(),
2667
+ body
2668
+ }, execute);
2543
2669
  }
2544
2670
  /**
2545
- * Итоговый URL со строкой запроса. Нужен и слою повторов для хука `onRetry`.
2671
+ * Сообщает хуку `onError` об ошибке попытки и отмечает её как переданную.
2546
2672
  *
2547
- * Хост берётся из самого запроса, если он там задан: у сервисов платформы свои домены.
2548
- */
2549
- buildUrl(request) {
2550
- const base = request.baseUrl ?? this.#config.baseUrl;
2551
- return joinUrl(base, request.path) + buildQuery(request.query);
2552
- }
2553
- /**
2554
- * Собирает общие заголовки клиента: `User-Agent`, идентификатор устройства,
2555
- * заголовки конфигурации и cookie для указанного адреса.
2673
+ * Ожидание хука ограничено lifecycle операции; срок попытки на него не действует.
2674
+ * Если ошибка попытки — сама отмена операции, о ней сообщает тот уровень, который заметил
2675
+ * её первым; второй хук не вызывает. Отмена, заставшая хук с другой ошибкой, остаётся за
2676
+ * верхней границей операции.
2677
+ *
2678
+ * @throws ошибку самого хука, если операция не отменена
2556
2679
  */
2557
- async platformHeaders(url, cookieJar = this.#deps.cookies) {
2558
- const headers = new Headers();
2559
- headers.set("X-Requested-With", "XMLHttpRequest");
2560
- if (this.#config.userAgent) setHeader(headers, "User-Agent", this.#config.userAgent);
2561
- if (this.#deps.getDeviceId) setHeader(headers, "X-Device-Id", await this.#deps.getDeviceId());
2562
- for (const [name, value] of Object.entries(this.#config.headers)) setHeader(headers, name, value);
2563
- if (this.#config.useCookieJar && cookieJar) {
2564
- const cookie = cookieJar.getHeader(url);
2565
- if (cookie) setHeader(headers, "Cookie", cookie);
2680
+ async #report(attempt, headers, error) {
2681
+ const { request } = attempt;
2682
+ attempt.abort.disarm();
2683
+ if (!hasRequestHook(this.#config.hooks, "onError")) return;
2684
+ markRequestErrorReported(request, error);
2685
+ const lifecycle = request.signal;
2686
+ const notify = () => dispatchRequestHook(this.#config.hooks, "onError", {
2687
+ ...this.#context(attempt, headers),
2688
+ duration: this.#elapsed(attempt),
2689
+ error
2690
+ });
2691
+ if (lifecycle?.aborted) {
2692
+ if (claimAbortReport(request)) notify().catch(() => {});
2693
+ return;
2694
+ }
2695
+ try {
2696
+ const pending = notify();
2697
+ await (lifecycle ? waitForRequest(pending, lifecycle) : pending);
2698
+ } catch (hookError) {
2699
+ if (lifecycle?.aborted) return;
2700
+ markRequestErrorReported(request, hookError);
2701
+ throw hookError;
2566
2702
  }
2567
- return headers;
2568
- }
2569
- /**
2570
- * Дополняет общие заголовки значением `Accept`, заголовками конвейера и вызова.
2571
- * Заголовки вызова применяются последними.
2572
- */
2573
- async #buildHeaders(request, url) {
2574
- const headers = await this.platformHeaders(url, request.cookieJar);
2575
- if (!headers.has("Accept")) headers.set("Accept", "application/json");
2576
- for (const [name, value] of Object.entries(request.layerHeaders ?? {})) setHeader(headers, name, value);
2577
- for (const [name, value] of Object.entries(request.headers ?? {})) setHeader(headers, name, value);
2578
- return headers;
2579
2703
  }
2580
- /** Превращает исключение `fetch` в понятную ошибку библиотеки. */
2581
- #toTransportError(error, abort, request, method, timeout) {
2582
- const aborted = abort.signal.aborted || error instanceof Error && error.name === "AbortError";
2583
- if (aborted && abort.timedOut()) return new ItdTimeoutError({
2584
- timeout,
2585
- method,
2586
- path: request.path
2587
- });
2588
- if (aborted) {
2704
+ /** Превращает исключение `fetch` или отмену в понятную ошибку библиотеки. */
2705
+ #toTransportError(attempt, error) {
2706
+ const { abort, method, request } = attempt;
2707
+ if (abort.signal.aborted || error instanceof Error && error.name === "AbortError") {
2708
+ const expired = abort.expired();
2709
+ if (expired) return expired;
2589
2710
  const reason = abort.signal.reason;
2711
+ if (reason instanceof ItdTimeoutError) return reason;
2590
2712
  return new ItdAbortError(`Запрос ${method} ${request.path} отменён`, reason !== void 0 ? { cause: reason } : void 0);
2591
2713
  }
2592
2714
  if (error instanceof ItdError) return error;
2593
- return new ItdNetworkError(`Не удалось выполнить ${method} ${request.path}: ${error instanceof Error ? error.message : String(error)}`, {
2715
+ return new ItdNetworkError(`Не удалось выполнить ${method} ${request.path}: ${describe(error)}`, {
2594
2716
  method,
2595
2717
  path: request.path,
2596
2718
  cause: error
@@ -2602,6 +2724,7 @@ var Transport = class {
2602
2724
  /** Имена стадий обработки запроса в порядке выполнения. @internal */
2603
2725
  const ClientRuntimeStage = Object.freeze({
2604
2726
  OperationPlugins: "operation_plugins",
2727
+ Decode: "decode",
2605
2728
  Services: "services",
2606
2729
  AuthPreflight: "auth_preflight",
2607
2730
  Retry: "retry",
@@ -2649,14 +2772,27 @@ function createClientRuntime(options, internals) {
2649
2772
  path: request.path
2650
2773
  }) ?? catalog.bucketOf(request.operationId);
2651
2774
  };
2652
- const queueKeyFor = (request) => requestQueueKey(request, (target) => ({
2653
- destination: originOf(transport.buildUrl(target)) || void 0,
2654
- bucket: bucketFor(target)
2655
- }));
2775
+ /** Origin направления, мемоизированный по базовому адресу. */
2776
+ const origins = /* @__PURE__ */ new Map();
2777
+ const destinationOf = (baseUrl) => {
2778
+ if (origins.has(baseUrl)) return origins.get(baseUrl);
2779
+ if (origins.size >= 256) origins.clear();
2780
+ const origin = originOf(baseUrl) || void 0;
2781
+ origins.set(baseUrl, origin);
2782
+ return origin;
2783
+ };
2784
+ const queueKeyFor = (request) => requestQueueKey(request, (target) => {
2785
+ const bucket = bucketFor(target);
2786
+ return {
2787
+ destination: destinationOf(target.baseUrl ?? config.baseUrl),
2788
+ bucket,
2789
+ definition: catalog.bucketDefinitionOf(bucket)
2790
+ };
2791
+ });
2656
2792
  const queueFor = (request) => {
2657
2793
  if (!queues) return void 0;
2658
2794
  const key = queueKeyFor(request);
2659
- return queues.for(key.destination, key.bucket);
2795
+ return queues.for(key.destination, key.bucket, key.definition);
2660
2796
  };
2661
2797
  /** Передаёт остаток из заголовков ответа бакету запроса; тот решает, тормозить ли себя. */
2662
2798
  const observeRateLimit = (limit, remaining, request) => {
@@ -2665,16 +2801,21 @@ function createClientRuntime(options, internals) {
2665
2801
  const waited = queue.observe(limit, remaining);
2666
2802
  if (waited > 0) config.logger?.debug(`остаток лимита ${remaining} из ${limit ?? "?"}, бакет ${queue.bucket} ждёт ${waited} мс`);
2667
2803
  };
2668
- transport = new Transport({
2669
- ...config,
2670
- hooks: config.hooks
2671
- }, {
2804
+ transport = new Transport(config, {
2672
2805
  onRateLimit: queues ? observeRateLimit : void 0,
2673
2806
  cookies: config.useCookieJar ? jar : void 0,
2674
- getDeviceId: () => auth.deviceId(),
2675
- lifetimeSignal: lifetime.signal
2807
+ getDeviceId: () => auth.deviceId()
2676
2808
  });
2677
- const stages = [
2809
+ /** Стадии логической операции: выполняются один раз на вызов метода. */
2810
+ const operationStages = [{
2811
+ name: ClientRuntimeStage.OperationPlugins,
2812
+ middleware: createPluginsMiddleware(plugins)
2813
+ }, {
2814
+ name: ClientRuntimeStage.Decode,
2815
+ middleware: createDecodeMiddleware()
2816
+ }];
2817
+ /** Стадии исполнения: от выбора сервиса до транспортной попытки. */
2818
+ const executionStages = [
2678
2819
  {
2679
2820
  name: ClientRuntimeStage.Services,
2680
2821
  middleware: createServicesMiddleware(services)
@@ -2689,7 +2830,7 @@ function createClientRuntime(options, internals) {
2689
2830
  clock: config.clock,
2690
2831
  catalog,
2691
2832
  retry: config.retry,
2692
- rateLimitDelays: config.rateLimit?.retryDelays ?? [],
2833
+ rateLimitDelays: config.rateLimit?.retryDelays,
2693
2834
  pauseQueue: queues ? (ms, request) => queueFor(request)?.pause(ms) : void 0,
2694
2835
  hooks: config.hooks,
2695
2836
  logger: config.logger,
@@ -2702,39 +2843,41 @@ function createClientRuntime(options, internals) {
2702
2843
  },
2703
2844
  {
2704
2845
  name: ClientRuntimeStage.AuthPreparation,
2705
- middleware: createAuthPreparationMiddleware({ prepare: () => auth.prepare() })
2846
+ middleware: createAuthPreparationMiddleware(() => auth)
2706
2847
  }
2707
2848
  ];
2708
- if (queues) stages.push({
2849
+ if (queues) executionStages.push({
2709
2850
  name: ClientRuntimeStage.Queue,
2710
2851
  middleware: createQueueMiddleware((request, task) => {
2711
2852
  const queue = queueFor(request);
2712
2853
  return queue ? queue.schedule(task, request.signal) : task();
2713
2854
  })
2714
2855
  });
2715
- stages.push({
2856
+ executionStages.push({
2716
2857
  name: ClientRuntimeStage.Attempt,
2717
2858
  middleware: createAttemptMiddleware()
2718
- });
2719
- stages.push({
2859
+ }, {
2720
2860
  name: ClientRuntimeStage.AuthHeaders,
2721
2861
  middleware: createAuthHeadersMiddleware({ currentHeaders: () => auth.currentHeaders() })
2722
2862
  });
2723
- const handler = composePipeline(stages.map(({ middleware }) => middleware), transport.send);
2724
- const clientHandler = (request) => {
2863
+ const execution = composePipeline(executionStages.map(({ middleware }) => middleware), transport.send);
2864
+ /**
2865
+ * Проверки между стадиями операции и исполнения: обёртка плагина уже могла изменить запрос,
2866
+ * а после начала `dispose()` завершаются только служебные запросы финализации.
2867
+ */
2868
+ const guardedExecution = (request) => {
2725
2869
  try {
2726
2870
  if (!isDisposeCleanupRequest(request)) internals.assertActive?.("выполнить новый запрос");
2727
2871
  if (request.rateLimitBucket !== void 0) assertKnownBucket(request.rateLimitBucket, "rateLimitBucket", config.rateLimit?.bucket, catalog);
2728
2872
  } catch (error) {
2729
2873
  return Promise.reject(error);
2730
2874
  }
2731
- return handler(request);
2875
+ return execution(request);
2732
2876
  };
2733
2877
  const http = new HttpClient({
2734
- handler: clientHandler,
2735
- plugins,
2878
+ handler: composePipeline(operationStages.map(({ middleware }) => middleware), guardedExecution),
2736
2879
  baseUrl: config.baseUrl,
2737
- timeout: config.timeout,
2880
+ deadline: config.deadline,
2738
2881
  clock: config.clock,
2739
2882
  lifetimeSignal: lifetime.signal,
2740
2883
  hooks: config.hooks,
@@ -2746,8 +2889,8 @@ function createClientRuntime(options, internals) {
2746
2889
  cookies: jar
2747
2890
  });
2748
2891
  const stageOrder = Object.freeze([
2749
- ClientRuntimeStage.OperationPlugins,
2750
- ...stages.map(({ name }) => name),
2892
+ ...operationStages.map(({ name }) => name),
2893
+ ...executionStages.map(({ name }) => name),
2751
2894
  ClientRuntimeStage.Transport
2752
2895
  ]);
2753
2896
  return {
@@ -2780,7 +2923,6 @@ function createClientRuntime(options, internals) {
2780
2923
  },
2781
2924
  platformHeaders: (url) => transport.platformHeaders(url),
2782
2925
  rateLimitState: () => queues?.states() ?? [],
2783
- registerRateLimitBucket: (name, definition) => queues?.defineBucket(name, definition),
2784
2926
  close: () => {
2785
2927
  if (ownsQueues) queues?.stop();
2786
2928
  },
@@ -3406,20 +3548,23 @@ function operationBucket(id) {
3406
3548
  }
3407
3549
  //#endregion
3408
3550
  //#region src/domain/catalog.ts
3551
+ /** Ограничения встроенных бакетов: ёмкость из таблицы плюс встроенные поправки. */
3552
+ const BUCKET_DEFINITIONS = Object.freeze(Object.fromEntries(Object.entries(BUCKET_LIMITS).map(([name, limit]) => [name, Object.freeze({
3553
+ limit,
3554
+ ...DEFAULT_BUCKET_OVERRIDES[name]
3555
+ })])));
3409
3556
  /**
3410
3557
  * Каталог операций итд.com, которым пользуется ядро.
3411
3558
  *
3412
3559
  * Единственное место, где generic request executor встречается с таблицами конкретного API.
3413
- * Собирается из уже существующих функций каталога: собственной логики здесь нет.
3414
3560
  *
3415
3561
  * @internal
3416
3562
  */
3417
3563
  const ITD_CATALOG = Object.freeze({
3418
3564
  definitionOf: (id) => isBuiltInOperationId(id) ? OPERATIONS[id] : void 0,
3419
- retrySafetyOf: (id) => isBuiltInOperationId(id) ? operationRetrySafety(id) : void 0,
3420
- methodOf: (id) => isBuiltInOperationId(id) ? operationMethod(id) : void 0,
3421
3565
  bucketOf: (id) => operationBucket(id),
3422
3566
  isKnownBucket,
3567
+ bucketDefinitionOf: (name) => BUCKET_DEFINITIONS[name],
3423
3568
  bucketLimits: BUCKET_LIMITS,
3424
3569
  bucketOverrides: DEFAULT_BUCKET_OVERRIDES,
3425
3570
  defaultBucket: DEFAULT_RATE_LIMIT_BUCKET
@@ -3914,7 +4059,6 @@ function localDeviceId() {
3914
4059
  function anonymousAuth() {
3915
4060
  return {
3916
4061
  token: () => Promise.resolve(null),
3917
- prepare: () => Promise.resolve(),
3918
4062
  currentHeaders: () => ({}),
3919
4063
  recover: () => Promise.resolve(false),
3920
4064
  deviceId: localDeviceId(),
@@ -3936,7 +4080,6 @@ function bearerToken(accessToken) {
3936
4080
  const headers = { Authorization: `Bearer ${accessToken}` };
3937
4081
  return {
3938
4082
  token: () => Promise.resolve(accessToken),
3939
- prepare: () => Promise.resolve(),
3940
4083
  currentHeaders: () => headers,
3941
4084
  recover: () => Promise.resolve(false),
3942
4085
  deviceId: localDeviceId(),
@@ -4106,6 +4249,6 @@ function resolveNotificationUrl(notification) {
4106
4249
  return clickUrl || "/notifications";
4107
4250
  }
4108
4251
  //#endregion
4109
- export { identityResult as $, SpanType as A, operationRetrySafety as B, ItdErrorCode as C, ReportReason as D, QrLocationPrecision as E, OPERATIONS as F, isRecord as G, DEFAULT_RATE_LIMIT_BUCKET as H, defineBuiltInOperation as I, pickNumber as J, pickArray as K, isBuiltInOperationId as L, ViewSource as M, WallAccess as N, ReportTargetType as O, ITD_CATALOG as P, defineOperation as Q, operationBucket as R, InteractionType as S, NotificationType as T, createClientRuntime as U, BUCKET_LIMITS as V, createApiError as W, pickString as X, pickObject as Y, RetrySafety as Z, CaptchaType as _, installAsyncDisposeFallback as _t, tokenProvider as a, assertPluginRemovable as at, FeedTab as b, normalizeNotification as c, DEFAULT_STATUS_BASE_URL as ct, canonicalNotificationType as d, LIBRARY_VERSION as dt, voidResult as et, isKnownNotificationType as f, isRecord$1 as ft, CaptchaField as g, systemClock as gt, CaptchaChoice as h, createDeadline as ht, bearerToken as i, RequestQueuePool as it, ViewReason as j, ServiceState as k, readNotificationEvent as l, STATUS_SERVICE as lt, AttachmentType as m, RateLimitPacing as mt, formatNotificationText as n, requestAbortError as nt, passthroughOperation as o, orderPluginDefinitions as ot, AccessType as p, requireOptionalBoolean as pt, pickBoolean as q, anonymousAuth as r, mergeService as rt, voidOperation as s, DEFAULT_BASE_URL as st, resolveNotificationUrl as t, createRequestAbortScope as tt, readUnreadCountEvent as u, resolveRateLimit as ut, CommentSort as v, LikesVisibility as w, IncidentKind as x, EventChannelStatus as y, operationMethod as z };
4252
+ export { identityResult as $, SpanType as A, operationRetrySafety as B, ItdErrorCode as C, ReportReason as D, QrLocationPrecision as E, OPERATIONS as F, pickArray as G, DEFAULT_RATE_LIMIT_BUCKET as H, defineBuiltInOperation as I, pickObject as J, pickBoolean as K, isBuiltInOperationId as L, ViewSource as M, WallAccess as N, ReportTargetType as O, ITD_CATALOG as P, defineOperation as Q, operationBucket as R, InteractionType as S, NotificationType as T, createClientRuntime as U, BUCKET_LIMITS as V, createApiError as W, OperationMethod as X, pickString as Y, RetrySafety as Z, CaptchaType as _, tokenProvider as a, assertPluginRemovable as at, FeedTab as b, normalizeNotification as c, DEFAULT_STATUS_BASE_URL as ct, canonicalNotificationType as d, LIBRARY_VERSION as dt, voidResult as et, isKnownNotificationType as f, RateLimitPacing as ft, CaptchaField as g, CaptchaChoice as h, installAsyncDisposeFallback as ht, bearerToken as i, RequestQueuePool as it, ViewReason as j, ServiceState as k, readNotificationEvent as l, STATUS_SERVICE as lt, AttachmentType as m, systemClock as mt, formatNotificationText as n, requestAbortError as nt, passthroughOperation as o, orderPluginDefinitions as ot, AccessType as p, createDeadline as pt, pickNumber as q, anonymousAuth as r, mergeService as rt, voidOperation as s, DEFAULT_BASE_URL as st, resolveNotificationUrl as t, createRequestAbortScope as tt, readUnreadCountEvent as u, resolveRateLimit as ut, CommentSort as v, LikesVisibility as w, IncidentKind as x, EventChannelStatus as y, operationMethod as z };
4110
4253
 
4111
- //# sourceMappingURL=url-DS0-lrl-.js.map
4254
+ //# sourceMappingURL=url-Bg61sk_Y.js.map