Skip to content

Commit 041ce78

Browse files
committed
Fix timeout to apply to total operation including retries
The `timeout` option now correctly applies to the entire operation including all retry attempts, as documented. Previously, each retry attempt received its own independent timeout. Fixes #784
1 parent 7003a19 commit 041ce78

File tree

4 files changed

+424
-60
lines changed

4 files changed

+424
-60
lines changed

source/core/Ky.ts

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {HTTPError} from '../errors/HTTPError.js';
22
import {NonError} from '../errors/NonError.js';
33
import {ForceRetryError} from '../errors/ForceRetryError.js';
4+
import {TimeoutError} from '../errors/TimeoutError.js';
45
import type {
56
Input,
67
InternalOptions,
@@ -53,6 +54,11 @@ export class Ky {
5354
throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`);
5455
}
5556

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+
5662
// Delay the fetch so that body method shortcuts can set the Accept header
5763
await Promise.resolve();
5864
// Before using ky.request, _fetch clones it and saves the clone for future retries to use.
@@ -210,6 +216,7 @@ export class Ky {
210216
#originalRequest?: Request;
211217
readonly #userProvidedAbortSignal?: AbortSignal;
212218
#cachedNormalizedOptions: NormalizedOptions | undefined;
219+
#startTime?: number;
213220

214221
// eslint-disable-next-line complexity
215222
constructor(input: Input, options: Options = {}) {
@@ -328,6 +335,11 @@ export class Ky {
328335
return Math.min(backoffLimit, jitteredDelay);
329336
}
330337

338+
#clampRetryDelayToMax(retryDelay: number): number {
339+
const {maxRetryAfter} = this.#options.retry;
340+
return maxRetryAfter === undefined ? retryDelay : Math.min(maxRetryAfter, retryDelay);
341+
}
342+
331343
async #calculateRetryDelay(error: unknown) {
332344
this.#retryCount++;
333345

@@ -389,9 +401,14 @@ export class Ky {
389401
after -= Date.now();
390402
}
391403

392-
const max = this.#options.retry.maxRetryAfter ?? after;
404+
if (!Number.isFinite(after)) {
405+
return this.#clampRetryDelayToMax(this.#calculateDelay());
406+
}
407+
408+
after = Math.max(0, after);
409+
393410
// Don't apply jitter when server provides explicit retry timing
394-
return after < max ? after : max;
411+
return this.#clampRetryDelayToMax(after);
395412
}
396413

397414
if (error.response.status === 413) {
@@ -533,13 +550,28 @@ export class Ky {
533550
try {
534551
return await function_();
535552
} catch (error) {
536-
const ms = Math.min(await this.#calculateRetryDelay(error), maxSafeTimeout);
537-
if (this.#retryCount < 1) {
538-
throw error;
553+
const retryDelay = Math.min(await this.#calculateRetryDelay(error), maxSafeTimeout);
554+
555+
let delayMs = retryDelay;
556+
const remainingTimeout = this.#getRemainingTimeout();
557+
if (remainingTimeout !== undefined) {
558+
if (remainingTimeout <= 0) {
559+
throw new TimeoutError(this.request);
560+
}
561+
562+
delayMs = Math.min(delayMs, remainingTimeout);
539563
}
540564

541565
// Only use user-provided signal for delay, not our internal abortController
542-
await delay(ms, this.#userProvidedAbortSignal ? {signal: this.#userProvidedAbortSignal} : {});
566+
await delay(delayMs, this.#userProvidedAbortSignal ? {signal: this.#userProvidedAbortSignal} : {});
567+
568+
const remainingTimeoutAfterDelay = this.#getRemainingTimeout();
569+
if (
570+
remainingTimeoutAfterDelay !== undefined
571+
&& remainingTimeoutAfterDelay <= 0
572+
) {
573+
throw new TimeoutError(this.request);
574+
}
543575

544576
// Apply custom request from forced retry before beforeRetry hooks
545577
// Ensure the custom request has the correct managed signal for timeouts and user aborts
@@ -576,6 +608,14 @@ export class Ky {
576608
}
577609
}
578610

611+
const remainingTimeoutAfterBeforeRetryHooks = this.#getRemainingTimeout();
612+
if (
613+
remainingTimeoutAfterBeforeRetryHooks !== undefined
614+
&& remainingTimeoutAfterBeforeRetryHooks <= 0
615+
) {
616+
throw new TimeoutError(this.request);
617+
}
618+
579619
return this.#retry(function_);
580620
}
581621
}
@@ -617,7 +657,28 @@ export class Ky {
617657
return this.#options.fetch(this.#originalRequest, nonRequestOptions);
618658
}
619659

620-
return timeout(this.#originalRequest, nonRequestOptions, this.#abortController, this.#options as TimeoutOptions);
660+
const remainingTimeout = this.#getRemainingTimeout() ?? this.#options.timeout;
661+
if (remainingTimeout <= 0) {
662+
throw new TimeoutError(this.request);
663+
}
664+
665+
return timeout(this.#originalRequest, nonRequestOptions, this.#abortController, {
666+
...this.#options,
667+
timeout: remainingTimeout,
668+
} as TimeoutOptions);
669+
}
670+
671+
#getRemainingTimeout(): number | undefined {
672+
if (this.#options.timeout === false) {
673+
return undefined;
674+
}
675+
676+
if (this.#startTime === undefined) {
677+
return this.#options.timeout;
678+
}
679+
680+
const elapsed = Date.now() - this.#startTime;
681+
return Math.max(0, this.#options.timeout - elapsed);
621682
}
622683

623684
#getNormalizedOptions(): NormalizedOptions {

test/hooks.ts

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import test from 'ava';
22
import delay from 'delay';
3-
import ky, {HTTPError, isHTTPError, isForceRetryError} from '../source/index.js';
3+
import ky, {
4+
HTTPError,
5+
isHTTPError,
6+
isForceRetryError,
7+
TimeoutError,
8+
} from '../source/index.js';
49
import {type Options} from '../source/types/options.js';
510
import {createHttpTestServer} from './helpers/create-http-test-server.js';
611

@@ -524,6 +529,73 @@ test('beforeRetry hook is never called for the initial request', async t => {
524529
await server.close();
525530
});
526531

532+
test('beforeRequest hook on initial request cannot bypass total timeout budget', async t => {
533+
let fetchCallCount = 0;
534+
535+
const customFetch: typeof fetch = async () => {
536+
fetchCallCount++;
537+
return new Response('ok');
538+
};
539+
540+
const error = await t.throwsAsync(
541+
ky('https://example.com', {
542+
fetch: customFetch,
543+
timeout: 100,
544+
hooks: {
545+
beforeRequest: [
546+
async () => {
547+
await delay(200);
548+
},
549+
],
550+
},
551+
}).text(),
552+
{
553+
name: 'TimeoutError',
554+
},
555+
);
556+
557+
t.true(error instanceof TimeoutError);
558+
t.is(fetchCallCount, 0);
559+
});
560+
561+
test('beforeRequest hook on retry cannot bypass total timeout budget', async t => {
562+
let fetchCallCount = 0;
563+
564+
const customFetch: typeof fetch = async () => {
565+
fetchCallCount++;
566+
if (fetchCallCount === 1) {
567+
return new Response('first-attempt', {status: 500});
568+
}
569+
570+
return new Response('second-attempt-success');
571+
};
572+
573+
await t.throwsAsync(
574+
ky('https://example.com', {
575+
fetch: customFetch,
576+
timeout: 100,
577+
retry: {
578+
limit: 1,
579+
delay: () => 0,
580+
},
581+
hooks: {
582+
beforeRequest: [
583+
async (_request, _options, state) => {
584+
if (state.retryCount > 0) {
585+
await delay(200);
586+
}
587+
},
588+
],
589+
},
590+
}).text(),
591+
{
592+
name: 'TimeoutError',
593+
},
594+
);
595+
596+
t.is(fetchCallCount, 1);
597+
});
598+
527599
test('beforeRetry hook allows modifications of non initial requests', async t => {
528600
let requestCount = 0;
529601

@@ -791,6 +863,45 @@ test('beforeRetry hook can cancel retries by returning `stop`', async t => {
791863
await server.close();
792864
});
793865

866+
test('beforeRetry hook respects total timeout budget', async t => {
867+
let fetchCallCount = 0;
868+
let beforeRetryCallCount = 0;
869+
870+
const customFetch: typeof fetch = async () => {
871+
fetchCallCount++;
872+
if (fetchCallCount === 1) {
873+
return new Response('first-attempt', {status: 500});
874+
}
875+
876+
return new Response('second-attempt-success');
877+
};
878+
879+
await t.throwsAsync(
880+
ky('https://example.com', {
881+
fetch: customFetch,
882+
timeout: 1000,
883+
retry: {
884+
limit: 1,
885+
delay: () => 0,
886+
},
887+
hooks: {
888+
beforeRetry: [
889+
async () => {
890+
beforeRetryCallCount++;
891+
await delay(2000);
892+
},
893+
],
894+
},
895+
}).text(),
896+
{
897+
name: 'TimeoutError',
898+
},
899+
);
900+
901+
t.is(beforeRetryCallCount, 1);
902+
t.is(fetchCallCount, 1);
903+
});
904+
794905
test('catches beforeRetry thrown errors', async t => {
795906
let requestCount = 0;
796907

@@ -1309,6 +1420,42 @@ test('afterResponse hook can force retry with custom delay', async t => {
13091420
await server.close();
13101421
});
13111422

1423+
test('afterResponse forced retry respects total timeout budget', async t => {
1424+
let requestCount = 0;
1425+
1426+
const server = await createHttpTestServer();
1427+
server.get('/', (_request, response) => {
1428+
requestCount++;
1429+
response.json({error: {code: 'RETRY_WITH_DELAY'}});
1430+
});
1431+
1432+
await t.throwsAsync(
1433+
ky.get(server.url, {
1434+
timeout: 100,
1435+
retry: {
1436+
limit: 3,
1437+
},
1438+
hooks: {
1439+
afterResponse: [
1440+
async (_request, _options, response) => {
1441+
const data = await response.clone().json();
1442+
if (data.error?.code === 'RETRY_WITH_DELAY') {
1443+
return ky.retry({delay: 200});
1444+
}
1445+
},
1446+
],
1447+
},
1448+
}),
1449+
{
1450+
name: 'TimeoutError',
1451+
},
1452+
);
1453+
1454+
t.true(requestCount <= 1);
1455+
1456+
await server.close();
1457+
});
1458+
13121459
test('afterResponse hook forced retry respects retry limit', async t => {
13131460
let requestCount = 0;
13141461

test/http-error.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,7 @@ test('HTTPError does not throw TypeError when error response stream is already l
537537
t.is(error?.data, undefined);
538538
});
539539

540-
test('retry is not blocked by never-ending error response body', async t => {
540+
test('never-ending error response body still respects total timeout budget', async t => {
541541
let requestCount = 0;
542542

543543
const customFetch: typeof fetch = async () => {
@@ -559,12 +559,15 @@ test('retry is not blocked by never-ending error response body', async t => {
559559
};
560560

561561
const start = Date.now();
562-
const text = await ky('https://example.com', {
562+
const error = await t.throwsAsync(ky('https://example.com', {
563563
fetch: customFetch,
564-
retry: 1,
565-
timeout: 50,
566-
}).text();
567-
t.is(text, 'ok');
568-
t.is(requestCount, 2);
564+
retry: {
565+
limit: 1,
566+
delay: () => 0,
567+
},
568+
timeout: 1000,
569+
}).text());
570+
t.is(error?.name, 'TimeoutError');
571+
t.is(requestCount, 1);
569572
t.true(Date.now() - start < 3000);
570573
});

0 commit comments

Comments
 (0)