Skip to content

Commit d833926

Browse files
committed
Replace retry.resetTimeout with top-level totalTimeout option
Make `timeout` per-attempt by default, matching how every major HTTP client works. Each retry now gets the full timeout value.
1 parent 21ed31e commit d833926

9 files changed

Lines changed: 403 additions & 172 deletions

File tree

readme.md

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -256,10 +256,9 @@ Default:
256256
- `delay`: `attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000`
257257
- `jitter`: `undefined`
258258
- `retryOnTimeout`: `false`
259-
- `resetTimeout`: `false`
260259
- `shouldRetry`: `undefined`
261260

262-
An object representing `limit`, `methods`, `statusCodes`, `afterStatusCodes`, `maxRetryAfter`, `backoffLimit`, `delay`, `jitter`, `retryOnTimeout`, `resetTimeout`, and `shouldRetry` fields for maximum retry count, allowed methods, allowed status codes, status codes allowed to use the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, maximum [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, backoff limit, delay calculation function, retry jitter, timeout retry behavior, timeout reset behavior, and custom retry logic.
261+
An object representing `limit`, `methods`, `statusCodes`, `afterStatusCodes`, `maxRetryAfter`, `backoffLimit`, `delay`, `jitter`, `retryOnTimeout`, and `shouldRetry` fields for maximum retry count, allowed methods, allowed status codes, status codes allowed to use the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, maximum [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, backoff limit, delay calculation function, retry jitter, timeout retry behavior, and custom retry logic.
263262

264263
If `retry` is a number, it will be used as `limit` and other defaults will remain in place.
265264

@@ -281,8 +280,6 @@ The `jitter` option adds random jitter to retry delays to prevent thundering her
281280

282281
The `retryOnTimeout` option determines whether to retry when a request times out. By default, retries are not triggered following a [timeout](#timeout).
283282

284-
The `resetTimeout` option gives each retry attempt the full `timeout` value instead of the remaining budget. By default, `timeout` is a total timeout across all retries, meaning later retries get progressively less time. When `resetTimeout` is `true`, each retry starts with a fresh timeout. If you need both per-request timeout and a total timeout cap, combine `resetTimeout: true` with `signal: AbortSignal.timeout(totalMs)`.
285-
286283
The `shouldRetry` option provides custom retry logic that **takes precedence over the default retry checks** (`retryOnTimeout`, status code checks, etc.) for retriable methods. It is only called after the retry limit and method checks pass.
287284

288285
**Note:** This is different from the `beforeRetry` hook:
@@ -323,21 +320,6 @@ const json = await ky('https://example.com', {
323320
}).json();
324321
```
325322

326-
**Resetting timeout on each retry:**
327-
328-
```js
329-
import ky from 'ky';
330-
331-
const json = await ky('https://example.com', {
332-
timeout: 5000,
333-
retry: {
334-
limit: 3,
335-
retryOnTimeout: true,
336-
resetTimeout: true
337-
}
338-
}).json();
339-
```
340-
341323
**Using jitter to prevent thundering herd:**
342324

343325
```js
@@ -401,10 +383,33 @@ const json = await ky('https://example.com', {
401383
Type: `number | false`\
402384
Default: `10000`
403385

404-
Timeout in milliseconds for getting a response, including any retries. Cannot be greater than 2147483647. Use [`retry.resetTimeout`](#retry) to give each retry attempt the full timeout instead of sharing a single budget across all attempts.
386+
Timeout in milliseconds for getting a response. Each retry attempt gets the full timeout. Cannot be greater than 2147483647.
405387

406388
If set to `false`, there will be no timeout.
407389

390+
##### totalTimeout
391+
392+
Type: `number | false`\
393+
Default: `false`
394+
395+
Total timeout in milliseconds for the entire operation, including all retries and delays. Cannot be greater than 2147483647. Throws a `TimeoutError` if exceeded.
396+
397+
This is useful when you want to cap the total time spent on an operation, while still allowing each individual retry to use the full per-attempt `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+
```
412+
408413
##### hooks
409414

410415
Type: `object<string, Function[]>`\

source/core/Ky.ts

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,12 @@ export class Ky {
9393
throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`);
9494
}
9595

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

@@ -302,6 +306,7 @@ export class Ky {
302306
retry: normalizeRetryOptions(options.retry),
303307
throwHttpErrors: options.throwHttpErrors ?? true,
304308
timeout: options.timeout ?? 10_000,
309+
totalTimeout: options.totalTimeout ?? false,
305310
fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
306311
context: options.context ?? {},
307312
};
@@ -698,7 +703,7 @@ export class Ky {
698703
const retryDelay = Math.min(await this.#calculateRetryDelay(error), maxSafeTimeout);
699704
const delayOptions = this.#userProvidedAbortSignal ? {signal: this.#userProvidedAbortSignal} : {};
700705

701-
const remainingTimeout = this.#getRemainingTimeout();
706+
const remainingTimeout = this.#getRemainingTotalTimeout();
702707
if (remainingTimeout !== undefined) {
703708
if (remainingTimeout <= 0) {
704709
throw new TimeoutError(this.request);
@@ -714,7 +719,7 @@ export class Ky {
714719
// Only use user-provided signal for delay, not our internal abortController
715720
await delay(retryDelay, delayOptions);
716721

717-
const remainingTimeoutAfterDelay = this.#getRemainingTimeout();
722+
const remainingTimeoutAfterDelay = this.#getRemainingTotalTimeout();
718723
if (
719724
remainingTimeoutAfterDelay !== undefined
720725
&& remainingTimeoutAfterDelay <= 0
@@ -769,7 +774,7 @@ export class Ky {
769774
}
770775
}
771776

772-
const remainingTimeoutAfterBeforeRetryHooks = this.#getRemainingTimeout();
777+
const remainingTimeoutAfterBeforeRetryHooks = this.#getRemainingTotalTimeout();
773778
if (
774779
remainingTimeoutAfterBeforeRetryHooks !== undefined
775780
&& remainingTimeoutAfterBeforeRetryHooks <= 0
@@ -809,18 +814,29 @@ export class Ky {
809814
}
810815

811816
try {
817+
const remainingTotal = this.#getRemainingTotalTimeout();
818+
if (remainingTotal !== undefined && remainingTotal <= 0) {
819+
throw new TimeoutError(this.request);
820+
}
821+
812822
if (this.#options.timeout === false) {
823+
if (remainingTotal !== undefined) {
824+
return await timeout(request, nonRequestOptions, this.#abortController, {
825+
...this.#options,
826+
timeout: remainingTotal,
827+
} as TimeoutOptions);
828+
}
829+
813830
return await this.#options.fetch(request, nonRequestOptions);
814831
}
815832

816-
const remainingTimeout = this.#getRemainingTimeout() ?? this.#options.timeout;
817-
if (remainingTimeout <= 0) {
818-
throw new TimeoutError(this.request);
819-
}
833+
const effectiveTimeout = remainingTotal === undefined
834+
? this.#options.timeout
835+
: Math.min(this.#options.timeout, remainingTotal);
820836

821837
return await timeout(request, nonRequestOptions, this.#abortController, {
822838
...this.#options,
823-
timeout: remainingTimeout,
839+
timeout: effectiveTimeout,
824840
} as TimeoutOptions);
825841
} catch (error) {
826842
if (isRawNetworkError(error)) {
@@ -831,17 +847,13 @@ export class Ky {
831847
}
832848
}
833849

834-
#getRemainingTimeout(): number | undefined {
835-
if (this.#options.timeout === false) {
850+
#getRemainingTotalTimeout(): number | undefined {
851+
if (this.#options.totalTimeout === false || this.#startTime === undefined) {
836852
return undefined;
837853
}
838854

839-
if (this.#startTime === undefined || this.#options.retry.resetTimeout) {
840-
return this.#options.timeout;
841-
}
842-
843855
const elapsed = this.#getCurrentTime() - this.#startTime;
844-
return Math.max(0, this.#options.timeout - elapsed);
856+
return Math.max(0, this.#options.totalTimeout - elapsed);
845857
}
846858

847859
#getCurrentTime(): number {
@@ -858,6 +870,7 @@ export class Ky {
858870
stringifyJson,
859871
searchParams,
860872
timeout,
873+
totalTimeout,
861874
throwHttpErrors,
862875
fetch,
863876
...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: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export type KyOptions = {
145145
prefix?: URL | string;
146146

147147
/**
148-
An object representing `limit`, `methods`, `statusCodes`, `afterStatusCodes`, `maxRetryAfter`, `backoffLimit`, `delay`, `jitter`, `retryOnTimeout`, `resetTimeout`, and `shouldRetry` fields for maximum retry count, allowed methods, allowed status codes, status codes allowed to use the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, maximum [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, backoff limit, delay calculation function, retry jitter, timeout retry behavior, timeout reset behavior, and custom retry logic.
148+
An object representing `limit`, `methods`, `statusCodes`, `afterStatusCodes`, `maxRetryAfter`, `backoffLimit`, `delay`, `jitter`, `retryOnTimeout`, and `shouldRetry` fields for maximum retry count, allowed methods, allowed status codes, status codes allowed to use the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, maximum [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, backoff limit, delay calculation function, retry jitter, timeout retry behavior, and custom retry logic.
149149
150150
If `retry` is a number, it will be used as `limit` and other defaults will remain in place.
151151
@@ -171,14 +171,40 @@ export type KyOptions = {
171171
retry?: RetryOptions | number;
172172

173173
/**
174-
Timeout in milliseconds for getting a response, including any retries. Cannot be greater than 2147483647. Use `retry.resetTimeout` to give each retry attempt the full timeout instead of sharing a single budget across all attempts.
174+
Timeout in milliseconds for getting a response. Each retry attempt gets the full timeout. Cannot be greater than 2147483647.
175175
176176
If set to `false`, there will be no timeout.
177177
178178
@default 10000
179179
*/
180180
timeout?: number | false;
181181

182+
/**
183+
Total timeout in milliseconds for the entire operation, including all retries and delays. Cannot be greater than 2147483647. Throws a `TimeoutError` if exceeded.
184+
185+
This is useful when you want to cap the total time spent on an operation, while still allowing each individual retry to use the full per-attempt `timeout`.
186+
187+
If set to `false` or not specified, there is no total timeout.
188+
189+
@default false
190+
191+
@example
192+
```
193+
import ky from 'ky';
194+
195+
// Each attempt gets 5s, but the whole operation must complete within 30s
196+
const json = await ky('https://example.com', {
197+
timeout: 5000,
198+
totalTimeout: 30_000,
199+
retry: {
200+
limit: 3,
201+
retryOnTimeout: true,
202+
}
203+
}).json();
204+
```
205+
*/
206+
totalTimeout?: number | false;
207+
182208
/**
183209
Hooks allow modifications during the request lifecycle. Hook functions may be async and are run serially.
184210
*/
@@ -393,7 +419,7 @@ export interface Options extends KyOptions, Omit<RequestInit, 'headers'> { // es
393419

394420
export type InternalOptions = Required<
395421
Omit<Options, 'hooks' | 'retry' | 'context' | 'throwHttpErrors'>,
396-
'fetch' | 'prefix' | 'timeout'
422+
'fetch' | 'prefix' | 'timeout' | 'totalTimeout'
397423
> & {
398424
headers: Required<Headers>;
399425
hooks: Required<Hooks>;

source/types/retry.ts

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -123,31 +123,6 @@ export type RetryOptions = {
123123
*/
124124
retryOnTimeout?: boolean;
125125

126-
/**
127-
Whether to reset the timeout for each retry attempt.
128-
129-
By default, the `timeout` option is a total timeout across all retries. When `resetTimeout` is `true`, each retry attempt gets the full `timeout` value instead of the remaining budget.
130-
131-
If you need both per-request timeout and a total timeout cap, combine `resetTimeout: true` with `signal: AbortSignal.timeout(totalMs)`.
132-
133-
@default false
134-
135-
@example
136-
```
137-
import ky from 'ky';
138-
139-
const json = await ky('https://example.com', {
140-
timeout: 5000,
141-
retry: {
142-
limit: 3,
143-
retryOnTimeout: true,
144-
resetTimeout: true
145-
}
146-
}).json();
147-
```
148-
*/
149-
resetTimeout?: boolean;
150-
151126
/**
152127
A function to determine whether a retry should be attempted.
153128

source/utils/normalize.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ const defaultRetryOptions: InternalRetryOptions = {
2323
delay: attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000,
2424
jitter: undefined,
2525
retryOnTimeout: false,
26-
resetTimeout: false,
2726
};
2827

2928
export const normalizeRetryOptions = (retry: number | RetryOptions = {}): InternalRetryOptions => {

test/hooks.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,7 @@ test('beforeRetry hook is never called for the initial request', async t => {
519519
);
520520
});
521521

522-
test('beforeRequest hook on initial request cannot bypass total timeout budget', async t => {
522+
test('beforeRequest hook on initial request cannot bypass totalTimeout budget', async t => {
523523
let fetchCallCount = 0;
524524

525525
const customFetch: typeof fetch = async () => {
@@ -530,7 +530,7 @@ test('beforeRequest hook on initial request cannot bypass total timeout budget',
530530
const error = await t.throwsAsync(
531531
ky('https://example.com', {
532532
fetch: customFetch,
533-
timeout: 100,
533+
totalTimeout: 100,
534534
hooks: {
535535
beforeRequest: [
536536
async () => {
@@ -839,7 +839,7 @@ test('beforeRetry hook can cancel retries by returning `stop`', async t => {
839839
t.is(requestCount, 1);
840840
});
841841

842-
test('beforeRetry hook respects total timeout budget', async t => {
842+
test('beforeRetry hook respects totalTimeout budget', async t => {
843843
let fetchCallCount = 0;
844844
let beforeRetryCallCount = 0;
845845

@@ -855,7 +855,7 @@ test('beforeRetry hook respects total timeout budget', async t => {
855855
await t.throwsAsync(
856856
ky('https://example.com', {
857857
fetch: customFetch,
858-
timeout: 1000,
858+
totalTimeout: 1000,
859859
retry: {
860860
limit: 1,
861861
delay: () => 0,
@@ -1534,7 +1534,7 @@ test('beforeError hook receives TimeoutError', async t => {
15341534
t.true(receivedError instanceof KyError);
15351535
});
15361536

1537-
test('beforeError receives TimeoutError when beforeRequest consumes remaining timeout budget (gh-508)', async t => {
1537+
test('beforeError receives TimeoutError when beforeRequest consumes remaining totalTimeout budget (gh-508)', async t => {
15381538
let receivedError: Error | undefined;
15391539
let fetchCallCount = 0;
15401540

@@ -1546,7 +1546,7 @@ test('beforeError receives TimeoutError when beforeRequest consumes remaining ti
15461546
await t.throwsAsync(
15471547
ky('https://example.com', {
15481548
fetch: customFetch,
1549-
timeout: 100,
1549+
totalTimeout: 100,
15501550
hooks: {
15511551
beforeRequest: [
15521552
async () => {
@@ -1685,9 +1685,8 @@ test('beforeError hook retryCount reflects actual retry count for TimeoutError',
16851685
},
16861686
);
16871687

1688-
// The 50ms total timeout budget is exhausted before any retry can complete,
1689-
// so retryCount is 0.
1690-
t.is(errorRetryCount, 0);
1688+
// Each retry gets the full per-attempt timeout, so all 2 retries are attempted.
1689+
t.is(errorRetryCount, 2);
16911690
});
16921691

16931692
test('beforeError hook can replace error with a different type', async t => {
@@ -2406,7 +2405,7 @@ test('afterResponse hook can force retry with custom delay', async t => {
24062405
t.true(elapsedTime >= customDelay); // Verify custom delay was used
24072406
});
24082407

2409-
test('afterResponse forced retry respects total timeout budget', async t => {
2408+
test('afterResponse forced retry respects totalTimeout budget', async t => {
24102409
let requestCount = 0;
24112410

24122411
const server = await createHttpTestServer(t);
@@ -2417,7 +2416,7 @@ test('afterResponse forced retry respects total timeout budget', async t => {
24172416

24182417
await t.throwsAsync(
24192418
ky.get(server.url, {
2420-
timeout: 100,
2419+
totalTimeout: 100,
24212420
retry: {
24222421
limit: 3,
24232422
},
@@ -2437,7 +2436,7 @@ test('afterResponse forced retry respects total timeout budget', async t => {
24372436
},
24382437
);
24392438

2440-
t.true(requestCount <= 1);
2439+
t.is(requestCount, 1);
24412440
});
24422441

24432442
test('afterResponse hook forced retry respects retry limit', async t => {

0 commit comments

Comments
 (0)