Skip to content

Commit 20567b6

Browse files
committed
Add retry.resetTimeout option to reset timeout budget on each retry
Fixes #497
1 parent 86b1519 commit 20567b6

File tree

6 files changed

+137
-5
lines changed

6 files changed

+137
-5
lines changed

readme.md

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

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.
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.
262263

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

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

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

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+
283286
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.
284287

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

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+
323341
**Using jitter to prevent thundering herd:**
324342

325343
```js
@@ -383,7 +401,8 @@ const json = await ky('https://example.com', {
383401
Type: `number | false`\
384402
Default: `10000`
385403

386-
Timeout in milliseconds for getting a response, including any retries. Can not be greater than 2147483647.
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.
405+
387406
If set to `false`, there will be no timeout.
388407

389408
##### hooks

source/core/Ky.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -836,7 +836,7 @@ export class Ky {
836836
return undefined;
837837
}
838838

839-
if (this.#startTime === undefined) {
839+
if (this.#startTime === undefined || this.#options.retry.resetTimeout) {
840840
return this.#options.timeout;
841841
}
842842

source/types/options.ts

Lines changed: 3 additions & 2 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`, 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.
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.
149149
150150
If `retry` is a number, it will be used as `limit` and other defaults will remain in place.
151151
@@ -171,7 +171,8 @@ export type KyOptions = {
171171
retry?: RetryOptions | number;
172172

173173
/**
174-
Timeout in milliseconds for getting a response, including any retries. Can not be greater than 2147483647.
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.
175+
175176
If set to `false`, there will be no timeout.
176177
177178
@default 10000

source/types/retry.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,31 @@ 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+
126151
/**
127152
A function to determine whether a retry should be attempted.
128153

source/utils/normalize.ts

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

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

test/retry.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1610,6 +1610,92 @@ test('shouldRetry: error propagates if shouldRetry returns rejected Promise', as
16101610
);
16111611
});
16121612

1613+
test('resetTimeout: true - each retry gets the full timeout', async t => {
1614+
let requestCount = 0;
1615+
1616+
const server = await createHttpTestServer(t);
1617+
server.get('/', async (_request, response) => {
1618+
requestCount++;
1619+
if (requestCount <= 2) {
1620+
await delay(600);
1621+
response.end(fixture);
1622+
return;
1623+
}
1624+
1625+
response.end(fixture);
1626+
});
1627+
1628+
const result = await ky(server.url, {
1629+
timeout: 500,
1630+
retry: {
1631+
limit: 3,
1632+
retryOnTimeout: true,
1633+
resetTimeout: true,
1634+
delay: () => 0,
1635+
},
1636+
}).text();
1637+
1638+
t.is(result, fixture);
1639+
t.is(requestCount, 3);
1640+
});
1641+
1642+
test('resetTimeout: true - works with HTTP error retries that consume timeout budget', async t => {
1643+
let requestCount = 0;
1644+
1645+
const server = await createHttpTestServer(t);
1646+
server.get('/', async (_request, response) => {
1647+
requestCount++;
1648+
if (requestCount <= 2) {
1649+
// Each failed request takes 600ms, consuming most of a 1000ms budget.
1650+
// Without resetTimeout, the first retry would have only ~400ms left and time out.
1651+
await delay(600);
1652+
response.sendStatus(500);
1653+
return;
1654+
}
1655+
1656+
response.end(fixture);
1657+
});
1658+
1659+
const result = await ky(server.url, {
1660+
timeout: 1000,
1661+
retry: {
1662+
limit: 3,
1663+
resetTimeout: true,
1664+
delay: () => 0,
1665+
},
1666+
}).text();
1667+
1668+
t.is(result, fixture);
1669+
t.is(requestCount, 3);
1670+
});
1671+
1672+
test('resetTimeout: true does not imply retryOnTimeout', async t => {
1673+
let requestCount = 0;
1674+
1675+
const server = await createHttpTestServer(t);
1676+
server.get('/', async (_request, response) => {
1677+
requestCount++;
1678+
await delay(600);
1679+
response.end(fixture);
1680+
});
1681+
1682+
await t.throwsAsync(
1683+
ky(server.url, {
1684+
timeout: 500,
1685+
retry: {
1686+
limit: 3,
1687+
resetTimeout: true,
1688+
// `retryOnTimeout` defaults to false
1689+
},
1690+
}).text(),
1691+
{
1692+
name: 'TimeoutError',
1693+
},
1694+
);
1695+
1696+
t.is(requestCount, 1);
1697+
});
1698+
16131699
test('NetworkError wraps fetch network errors', async t => {
16141700
const error = await t.throwsAsync(
16151701
ky('https://example.com', {

0 commit comments

Comments
 (0)