Skip to content

Commit c20d7c7

Browse files
authored
Add totalTimeout option and make timeout be per retry (#848)
1 parent 3713ce8 commit c20d7c7

7 files changed

Lines changed: 604 additions & 97 deletions

File tree

readme.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -383,8 +383,32 @@ const json = await ky('https://example.com', {
383383
Type: `number | false`\
384384
Default: `10000`
385385

386-
Timeout in milliseconds for getting a response, including any retries. Can not be greater than 2147483647.
387-
If set to `false`, there will be no timeout.
386+
Per-attempt timeout in milliseconds for getting a response, applied independently to each retry. Cannot be greater than 2147483647. See also [`totalTimeout`](#totaltimeout).
387+
388+
If set to `false`, there will be no per-attempt timeout.
389+
390+
##### totalTimeout
391+
392+
Type: `number | false`\
393+
Default: `false`
394+
395+
Overall timeout in milliseconds for the entire operation, including retries and delays. Throws a `TimeoutError` if exceeded. Cannot be greater than 2147483647.
396+
397+
If set to `false` or not specified, there is no overall timeout.
398+
399+
```js
400+
import ky from 'ky';
401+
402+
// Each attempt gets 5s, but the whole operation must complete within 30s
403+
const json = await ky('https://example.com', {
404+
timeout: 5000,
405+
totalTimeout: 30_000,
406+
retry: {
407+
limit: 3,
408+
retryOnTimeout: true,
409+
}
410+
}).json();
411+
```
388412

389413
##### hooks
390414

source/core/Ky.ts

Lines changed: 78 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737

3838
const maxErrorResponseBodySize = 10 * 1024 * 1024;
3939
const prefixUrlRenamedErrorMessage = 'The `prefixUrl` option has been renamed `prefix` in v2 and enhanced to allow slashes in input. See also the new `baseUrl` option for improved flexibility with standard URL resolution: https://github.com/sindresorhus/ky#baseurl';
40+
const timedOutResponseData = Symbol('timedOutResponseData');
4041

4142
const createTextDecoder = (contentType: string): TextDecoder => {
4243
const match = /;\s*charset\s*=\s*(?:"([^"]+)"|([^;,\s]+))/i.exec(contentType);
@@ -93,8 +94,12 @@ export class Ky {
9394
throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`);
9495
}
9596

96-
// Track start time for total timeout across retries
97-
if (ky.#startTime === undefined && typeof ky.#options.timeout === 'number') {
97+
if (typeof ky.#options.totalTimeout === 'number' && ky.#options.totalTimeout > maxSafeTimeout) {
98+
throw new RangeError(`The \`totalTimeout\` option cannot be greater than ${maxSafeTimeout}`);
99+
}
100+
101+
// Track start time for totalTimeout across retries
102+
if (ky.#startTime === undefined && typeof ky.#options.totalTimeout === 'number') {
98103
ky.#startTime = ky.#getCurrentTime();
99104
}
100105

@@ -303,6 +308,7 @@ export class Ky {
303308
retry: normalizeRetryOptions(options.retry),
304309
throwHttpErrors: options.throwHttpErrors ?? true,
305310
timeout: options.timeout ?? 10_000,
311+
totalTimeout: options.totalTimeout ?? false,
306312
fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
307313
context: options.context ?? {},
308314
};
@@ -500,8 +506,15 @@ export class Ky {
500506
async #getResponseData(response: Response): Promise<unknown> {
501507
// Even with request timeouts disabled, bound error-body reads so retries and error propagation
502508
// cannot be stalled indefinitely by never-ending response streams.
503-
const errorDataTimeout = this.#options.timeout === false ? 10_000 : this.#options.timeout;
504-
const text = await this.#readResponseText(response, errorDataTimeout);
509+
const readTimeout = this.#getErrorDataTimeout();
510+
const text = await this.#readResponseText(response, readTimeout.timeout);
511+
if (text === timedOutResponseData) {
512+
if (readTimeout.totalTimeoutReachedOnTimeout) {
513+
throw new TimeoutError(this.request);
514+
}
515+
516+
return undefined;
517+
}
505518

506519
if (!text) {
507520
return undefined;
@@ -511,7 +524,38 @@ export class Ky {
511524
return text;
512525
}
513526

514-
return this.#parseJson(text, response, errorDataTimeout);
527+
const parseTimeout = this.#getErrorDataTimeout();
528+
const data = await this.#parseJson(text, response, parseTimeout.timeout);
529+
if (data === timedOutResponseData) {
530+
if (parseTimeout.totalTimeoutReachedOnTimeout) {
531+
throw new TimeoutError(this.request);
532+
}
533+
534+
return undefined;
535+
}
536+
537+
return data;
538+
}
539+
540+
#getErrorDataTimeout(): {timeout: number; totalTimeoutReachedOnTimeout: boolean} {
541+
const errorDataTimeout = this.#options.timeout === false ? 10_000 : this.#options.timeout;
542+
const remainingTotal = this.#getRemainingTotalTimeout();
543+
544+
if (remainingTotal === undefined) {
545+
return {
546+
timeout: errorDataTimeout,
547+
totalTimeoutReachedOnTimeout: false,
548+
};
549+
}
550+
551+
if (remainingTotal <= 0) {
552+
throw new TimeoutError(this.request);
553+
}
554+
555+
return {
556+
timeout: Math.min(errorDataTimeout, remainingTotal),
557+
totalTimeoutReachedOnTimeout: remainingTotal <= errorDataTimeout,
558+
};
515559
}
516560

517561
#isJsonContentType(contentType: string): boolean {
@@ -520,7 +564,7 @@ export class Ky {
520564
return /\/(?:.*[.+-])?json$/.test(mimeType);
521565
}
522566

523-
async #readResponseText(response: Response, timeoutMs: number): Promise<string | undefined> {
567+
async #readResponseText(response: Response, timeoutMs: number): Promise<string | typeof timedOutResponseData | undefined> {
524568
const {body} = response;
525569
if (!body) {
526570
try {
@@ -567,17 +611,17 @@ export class Ky {
567611
return chunks.join('');
568612
})();
569613

570-
const timeoutPromise = new Promise<undefined>(resolve => {
614+
const timeoutPromise = new Promise<typeof timedOutResponseData>(resolve => {
571615
const timeoutId = setTimeout(() => {
572-
resolve(undefined);
616+
resolve(timedOutResponseData);
573617
}, timeoutMs);
574618
void readAll.finally(() => {
575619
clearTimeout(timeoutId);
576620
});
577621
});
578622

579623
const result = await Promise.race([readAll, timeoutPromise]);
580-
if (result === undefined) {
624+
if (result === timedOutResponseData) {
581625
void reader.cancel().catch(() => undefined);
582626
}
583627

@@ -592,9 +636,9 @@ export class Ky {
592636
? this.#options.parseJson(text, {request: this.request, response})
593637
: JSON.parse(text),
594638
),
595-
new Promise<undefined>(resolve => {
639+
new Promise<typeof timedOutResponseData>(resolve => {
596640
timeoutId = setTimeout(() => {
597-
resolve(undefined);
641+
resolve(timedOutResponseData);
598642
}, timeoutMs);
599643
}),
600644
]);
@@ -702,7 +746,7 @@ export class Ky {
702746
const retryDelay = Math.min(await this.#calculateRetryDelay(error), maxSafeTimeout);
703747
const delayOptions = this.#userProvidedAbortSignal ? {signal: this.#userProvidedAbortSignal} : {};
704748

705-
const remainingTimeout = this.#getRemainingTimeout();
749+
const remainingTimeout = this.#getRemainingTotalTimeout();
706750
if (remainingTimeout !== undefined) {
707751
if (remainingTimeout <= 0) {
708752
throw new TimeoutError(this.request);
@@ -718,7 +762,7 @@ export class Ky {
718762
// Only use user-provided signal for delay, not our internal abortController
719763
await delay(retryDelay, delayOptions);
720764

721-
const remainingTimeoutAfterDelay = this.#getRemainingTimeout();
765+
const remainingTimeoutAfterDelay = this.#getRemainingTotalTimeout();
722766
if (
723767
remainingTimeoutAfterDelay !== undefined
724768
&& remainingTimeoutAfterDelay <= 0
@@ -773,7 +817,7 @@ export class Ky {
773817
}
774818
}
775819

776-
const remainingTimeoutAfterBeforeRetryHooks = this.#getRemainingTimeout();
820+
const remainingTimeoutAfterBeforeRetryHooks = this.#getRemainingTotalTimeout();
777821
if (
778822
remainingTimeoutAfterBeforeRetryHooks !== undefined
779823
&& remainingTimeoutAfterBeforeRetryHooks <= 0
@@ -813,18 +857,29 @@ export class Ky {
813857
}
814858

815859
try {
860+
const remainingTotal = this.#getRemainingTotalTimeout();
861+
if (remainingTotal !== undefined && remainingTotal <= 0) {
862+
throw new TimeoutError(this.request);
863+
}
864+
816865
if (this.#options.timeout === false) {
866+
if (remainingTotal !== undefined) {
867+
return await timeout(request, nonRequestOptions, this.#abortController, {
868+
...this.#options,
869+
timeout: remainingTotal,
870+
} as TimeoutOptions);
871+
}
872+
817873
return await this.#options.fetch(request, nonRequestOptions);
818874
}
819875

820-
const remainingTimeout = this.#getRemainingTimeout() ?? this.#options.timeout;
821-
if (remainingTimeout <= 0) {
822-
throw new TimeoutError(this.request);
823-
}
876+
const effectiveTimeout = remainingTotal === undefined
877+
? this.#options.timeout
878+
: Math.min(this.#options.timeout, remainingTotal);
824879

825880
return await timeout(request, nonRequestOptions, this.#abortController, {
826881
...this.#options,
827-
timeout: remainingTimeout,
882+
timeout: effectiveTimeout,
828883
} as TimeoutOptions);
829884
} catch (error) {
830885
if (isRawNetworkError(error)) {
@@ -835,17 +890,13 @@ export class Ky {
835890
}
836891
}
837892

838-
#getRemainingTimeout(): number | undefined {
839-
if (this.#options.timeout === false) {
893+
#getRemainingTotalTimeout(): number | undefined {
894+
if (this.#options.totalTimeout === false || this.#startTime === undefined) {
840895
return undefined;
841896
}
842897

843-
if (this.#startTime === undefined) {
844-
return this.#options.timeout;
845-
}
846-
847898
const elapsed = this.#getCurrentTime() - this.#startTime;
848-
return Math.max(0, this.#options.timeout - elapsed);
899+
return Math.max(0, this.#options.totalTimeout - elapsed);
849900
}
850901

851902
#getCurrentTime(): number {
@@ -862,6 +913,7 @@ export class Ky {
862913
stringifyJson,
863914
searchParams,
864915
timeout,
916+
totalTimeout,
865917
throwHttpErrors,
866918
fetch,
867919
...normalizedOptions

source/core/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ export const kyOptionKeys: KyOptionsRegistry = {
250250
prefix: true,
251251
retry: true,
252252
timeout: true,
253+
totalTimeout: true,
253254
hooks: true,
254255
throwHttpErrors: true,
255256
onDownloadProgress: true,

source/types/options.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,13 +187,38 @@ export type KyOptions = {
187187
retry?: RetryOptions | number;
188188

189189
/**
190-
Timeout in milliseconds for getting a response, including any retries. Can not be greater than 2147483647.
191-
If set to `false`, there will be no timeout.
190+
Per-attempt timeout in milliseconds for getting a response, applied independently to each retry. Cannot be greater than 2147483647. See also `totalTimeout`.
191+
192+
If set to `false`, there will be no per-attempt timeout.
192193
193194
@default 10000
194195
*/
195196
timeout?: number | false;
196197

198+
/**
199+
Overall timeout in milliseconds for the entire operation, including retries and delays. Throws a `TimeoutError` if exceeded. Cannot be greater than 2147483647.
200+
201+
If set to `false` or not specified, there is no overall timeout.
202+
203+
@default false
204+
205+
@example
206+
```
207+
import ky from 'ky';
208+
209+
// Each attempt gets 5s, but the whole operation must complete within 30s
210+
const json = await ky('https://example.com', {
211+
timeout: 5000,
212+
totalTimeout: 30_000,
213+
retry: {
214+
limit: 3,
215+
retryOnTimeout: true,
216+
}
217+
}).json();
218+
```
219+
*/
220+
totalTimeout?: number | false;
221+
197222
/**
198223
Hooks allow modifications during the request lifecycle. Hook functions may be async and are run serially.
199224
*/
@@ -410,7 +435,7 @@ export interface Options extends KyOptions, Omit<RequestInit, 'headers'> { // es
410435

411436
export type InternalOptions = Required<
412437
Omit<Options, 'hooks' | 'retry' | 'context' | 'throwHttpErrors'>,
413-
'fetch' | 'prefix' | 'timeout'
438+
'fetch' | 'prefix' | 'timeout' | 'totalTimeout'
414439
> & {
415440
headers: Required<Headers>;
416441
hooks: Required<Hooks>;

0 commit comments

Comments
 (0)