|
1 | 1 | import {HTTPError} from '../errors/HTTPError.js'; |
2 | 2 | import {NonError} from '../errors/NonError.js'; |
3 | 3 | import {ForceRetryError} from '../errors/ForceRetryError.js'; |
| 4 | +import {TimeoutError} from '../errors/TimeoutError.js'; |
4 | 5 | import type { |
5 | 6 | Input, |
6 | 7 | InternalOptions, |
@@ -53,6 +54,11 @@ export class Ky { |
53 | 54 | throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`); |
54 | 55 | } |
55 | 56 |
|
| 57 | + // Track start time for total timeout across retries |
| 58 | + if (ky.#startTime === undefined && typeof ky.#options.timeout === 'number') { |
| 59 | + ky.#startTime = Date.now(); |
| 60 | + } |
| 61 | + |
56 | 62 | // Delay the fetch so that body method shortcuts can set the Accept header |
57 | 63 | await Promise.resolve(); |
58 | 64 | // Before using ky.request, _fetch clones it and saves the clone for future retries to use. |
@@ -211,6 +217,7 @@ export class Ky { |
211 | 217 | #originalRequest?: Request; |
212 | 218 | readonly #userProvidedAbortSignal?: AbortSignal; |
213 | 219 | #cachedNormalizedOptions: NormalizedOptions | undefined; |
| 220 | + #startTime?: number; |
214 | 221 |
|
215 | 222 | // eslint-disable-next-line complexity |
216 | 223 | constructor(input: Input, options: Options = {}) { |
@@ -329,6 +336,11 @@ export class Ky { |
329 | 336 | return Math.min(backoffLimit, jitteredDelay); |
330 | 337 | } |
331 | 338 |
|
| 339 | + #clampRetryDelayToMax(retryDelay: number): number { |
| 340 | + const {maxRetryAfter} = this.#options.retry; |
| 341 | + return maxRetryAfter === undefined ? retryDelay : Math.min(maxRetryAfter, retryDelay); |
| 342 | + } |
| 343 | + |
332 | 344 | async #calculateRetryDelay(error: unknown) { |
333 | 345 | this.#retryCount++; |
334 | 346 |
|
@@ -390,9 +402,14 @@ export class Ky { |
390 | 402 | after -= Date.now(); |
391 | 403 | } |
392 | 404 |
|
393 | | - const max = this.#options.retry.maxRetryAfter ?? after; |
| 405 | + if (!Number.isFinite(after)) { |
| 406 | + return this.#clampRetryDelayToMax(this.#calculateDelay()); |
| 407 | + } |
| 408 | + |
| 409 | + after = Math.max(0, after); |
| 410 | + |
394 | 411 | // Don't apply jitter when server provides explicit retry timing |
395 | | - return Math.min(after, max); |
| 412 | + return this.#clampRetryDelayToMax(after); |
396 | 413 | } |
397 | 414 |
|
398 | 415 | if (error.response.status === 413) { |
@@ -534,13 +551,35 @@ export class Ky { |
534 | 551 | try { |
535 | 552 | return await function_(); |
536 | 553 | } catch (error) { |
537 | | - const ms = Math.min(await this.#calculateRetryDelay(error), maxSafeTimeout); |
538 | | - if (this.#retryCount < 1) { |
539 | | - throw error; |
| 554 | + const retryDelay = Math.min(await this.#calculateRetryDelay(error), maxSafeTimeout); |
| 555 | + const delayOptions = this.#userProvidedAbortSignal ? {signal: this.#userProvidedAbortSignal} : {}; |
| 556 | + |
| 557 | + let delayMs = retryDelay; |
| 558 | + const remainingTimeout = this.#getRemainingTimeout(); |
| 559 | + if (remainingTimeout !== undefined) { |
| 560 | + if (remainingTimeout <= 0) { |
| 561 | + throw new TimeoutError(this.request); |
| 562 | + } |
| 563 | + |
| 564 | + // If waiting would consume all remaining budget, time out without starting another request. |
| 565 | + if (delayMs >= remainingTimeout) { |
| 566 | + await delay(remainingTimeout, delayOptions); |
| 567 | + throw new TimeoutError(this.request); |
| 568 | + } |
| 569 | + |
| 570 | + delayMs = Math.min(delayMs, remainingTimeout); |
540 | 571 | } |
541 | 572 |
|
542 | 573 | // Only use user-provided signal for delay, not our internal abortController |
543 | | - await delay(ms, this.#userProvidedAbortSignal ? {signal: this.#userProvidedAbortSignal} : {}); |
| 574 | + await delay(delayMs, delayOptions); |
| 575 | + |
| 576 | + const remainingTimeoutAfterDelay = this.#getRemainingTimeout(); |
| 577 | + if ( |
| 578 | + remainingTimeoutAfterDelay !== undefined |
| 579 | + && remainingTimeoutAfterDelay <= 0 |
| 580 | + ) { |
| 581 | + throw new TimeoutError(this.request); |
| 582 | + } |
544 | 583 |
|
545 | 584 | // Apply custom request from forced retry before beforeRetry hooks |
546 | 585 | // Ensure the custom request has the correct managed signal for timeouts and user aborts |
@@ -577,6 +616,14 @@ export class Ky { |
577 | 616 | } |
578 | 617 | } |
579 | 618 |
|
| 619 | + const remainingTimeoutAfterBeforeRetryHooks = this.#getRemainingTimeout(); |
| 620 | + if ( |
| 621 | + remainingTimeoutAfterBeforeRetryHooks !== undefined |
| 622 | + && remainingTimeoutAfterBeforeRetryHooks <= 0 |
| 623 | + ) { |
| 624 | + throw new TimeoutError(this.request); |
| 625 | + } |
| 626 | + |
580 | 627 | return this.#retry(function_); |
581 | 628 | } |
582 | 629 | } |
@@ -618,7 +665,28 @@ export class Ky { |
618 | 665 | return this.#options.fetch(this.#originalRequest, nonRequestOptions); |
619 | 666 | } |
620 | 667 |
|
621 | | - return timeout(this.#originalRequest, nonRequestOptions, this.#abortController, this.#options as TimeoutOptions); |
| 668 | + const remainingTimeout = this.#getRemainingTimeout() ?? this.#options.timeout; |
| 669 | + if (remainingTimeout <= 0) { |
| 670 | + throw new TimeoutError(this.request); |
| 671 | + } |
| 672 | + |
| 673 | + return timeout(this.#originalRequest, nonRequestOptions, this.#abortController, { |
| 674 | + ...this.#options, |
| 675 | + timeout: remainingTimeout, |
| 676 | + } as TimeoutOptions); |
| 677 | + } |
| 678 | + |
| 679 | + #getRemainingTimeout(): number | undefined { |
| 680 | + if (this.#options.timeout === false) { |
| 681 | + return undefined; |
| 682 | + } |
| 683 | + |
| 684 | + if (this.#startTime === undefined) { |
| 685 | + return this.#options.timeout; |
| 686 | + } |
| 687 | + |
| 688 | + const elapsed = Date.now() - this.#startTime; |
| 689 | + return Math.max(0, this.#options.timeout - elapsed); |
622 | 690 | } |
623 | 691 |
|
624 | 692 | #getNormalizedOptions(): NormalizedOptions { |
|
0 commit comments