Skip to content

Commit 429da77

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 ecdd45e commit 429da77

File tree

4 files changed

+424
-62
lines changed

4 files changed

+424
-62
lines changed

source/core/Ky.ts

Lines changed: 75 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.
@@ -211,6 +217,7 @@ export class Ky {
211217
#originalRequest?: Request;
212218
readonly #userProvidedAbortSignal?: AbortSignal;
213219
#cachedNormalizedOptions: NormalizedOptions | undefined;
220+
#startTime?: number;
214221

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

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

@@ -390,9 +402,14 @@ export class Ky {
390402
after -= Date.now();
391403
}
392404

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+
394411
// Don't apply jitter when server provides explicit retry timing
395-
return Math.min(after, max);
412+
return this.#clampRetryDelayToMax(after);
396413
}
397414

398415
if (error.response.status === 413) {
@@ -534,13 +551,35 @@ export class Ky {
534551
try {
535552
return await function_();
536553
} 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);
540571
}
541572

542573
// 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+
}
544583

545584
// Apply custom request from forced retry before beforeRetry hooks
546585
// Ensure the custom request has the correct managed signal for timeouts and user aborts
@@ -577,6 +616,14 @@ export class Ky {
577616
}
578617
}
579618

619+
const remainingTimeoutAfterBeforeRetryHooks = this.#getRemainingTimeout();
620+
if (
621+
remainingTimeoutAfterBeforeRetryHooks !== undefined
622+
&& remainingTimeoutAfterBeforeRetryHooks <= 0
623+
) {
624+
throw new TimeoutError(this.request);
625+
}
626+
580627
return this.#retry(function_);
581628
}
582629
}
@@ -618,7 +665,28 @@ export class Ky {
618665
return this.#options.fetch(this.#originalRequest, nonRequestOptions);
619666
}
620667

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);
622690
}
623691

624692
#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 {setTimeout as delay} from 'node:timers/promises';
22
import test from 'ava';
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

@@ -500,6 +505,73 @@ test('beforeRetry hook is never called for the initial request', async t => {
500505
);
501506
});
502507

508+
test('beforeRequest hook on initial request cannot bypass total timeout budget', async t => {
509+
let fetchCallCount = 0;
510+
511+
const customFetch: typeof fetch = async () => {
512+
fetchCallCount++;
513+
return new Response('ok');
514+
};
515+
516+
const error = await t.throwsAsync(
517+
ky('https://example.com', {
518+
fetch: customFetch,
519+
timeout: 100,
520+
hooks: {
521+
beforeRequest: [
522+
async () => {
523+
await delay(200);
524+
},
525+
],
526+
},
527+
}).text(),
528+
{
529+
name: 'TimeoutError',
530+
},
531+
);
532+
533+
t.true(error instanceof TimeoutError);
534+
t.is(fetchCallCount, 0);
535+
});
536+
537+
test('beforeRequest hook on retry cannot bypass total timeout budget', async t => {
538+
let fetchCallCount = 0;
539+
540+
const customFetch: typeof fetch = async () => {
541+
fetchCallCount++;
542+
if (fetchCallCount === 1) {
543+
return new Response('first-attempt', {status: 500});
544+
}
545+
546+
return new Response('second-attempt-success');
547+
};
548+
549+
await t.throwsAsync(
550+
ky('https://example.com', {
551+
fetch: customFetch,
552+
timeout: 100,
553+
retry: {
554+
limit: 1,
555+
delay: () => 0,
556+
},
557+
hooks: {
558+
beforeRequest: [
559+
async ({retryCount}) => {
560+
if (retryCount > 0) {
561+
await delay(200);
562+
}
563+
},
564+
],
565+
},
566+
}).text(),
567+
{
568+
name: 'TimeoutError',
569+
},
570+
);
571+
572+
t.is(fetchCallCount, 1);
573+
});
574+
503575
test('beforeRetry hook allows modifications of non initial requests', async t => {
504576
let requestCount = 0;
505577

@@ -753,6 +825,45 @@ test('beforeRetry hook can cancel retries by returning `stop`', async t => {
753825
t.is(requestCount, 1);
754826
});
755827

828+
test('beforeRetry hook respects total timeout budget', async t => {
829+
let fetchCallCount = 0;
830+
let beforeRetryCallCount = 0;
831+
832+
const customFetch: typeof fetch = async () => {
833+
fetchCallCount++;
834+
if (fetchCallCount === 1) {
835+
return new Response('first-attempt', {status: 500});
836+
}
837+
838+
return new Response('second-attempt-success');
839+
};
840+
841+
await t.throwsAsync(
842+
ky('https://example.com', {
843+
fetch: customFetch,
844+
timeout: 1000,
845+
retry: {
846+
limit: 1,
847+
delay: () => 0,
848+
},
849+
hooks: {
850+
beforeRetry: [
851+
async () => {
852+
beforeRetryCallCount++;
853+
await delay(2000);
854+
},
855+
],
856+
},
857+
}).text(),
858+
{
859+
name: 'TimeoutError',
860+
},
861+
);
862+
863+
t.is(beforeRetryCallCount, 1);
864+
t.is(fetchCallCount, 1);
865+
});
866+
756867
test('catches beforeRetry thrown errors', async t => {
757868
let requestCount = 0;
758869

@@ -1249,6 +1360,42 @@ test('afterResponse hook can force retry with custom delay', async t => {
12491360
t.true(elapsedTime >= customDelay); // Verify custom delay was used
12501361
});
12511362

1363+
test('afterResponse forced retry respects total timeout budget', async t => {
1364+
let requestCount = 0;
1365+
1366+
const server = await createHttpTestServer();
1367+
server.get('/', (_request, response) => {
1368+
requestCount++;
1369+
response.json({error: {code: 'RETRY_WITH_DELAY'}});
1370+
});
1371+
1372+
await t.throwsAsync(
1373+
ky.get(server.url, {
1374+
timeout: 100,
1375+
retry: {
1376+
limit: 3,
1377+
},
1378+
hooks: {
1379+
afterResponse: [
1380+
async (_request, _options, response) => {
1381+
const data = await response.clone().json();
1382+
if (data.error?.code === 'RETRY_WITH_DELAY') {
1383+
return ky.retry({delay: 200});
1384+
}
1385+
},
1386+
],
1387+
},
1388+
}),
1389+
{
1390+
name: 'TimeoutError',
1391+
},
1392+
);
1393+
1394+
t.true(requestCount <= 1);
1395+
1396+
await server.close();
1397+
});
1398+
12521399
test('afterResponse hook forced retry respects retry limit', async t => {
12531400
let requestCount = 0;
12541401

test/http-error.ts

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

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

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

523523
const start = Date.now();
524-
const text = await ky('https://example.com', {
524+
const error = await t.throwsAsync(ky('https://example.com', {
525525
fetch: customFetch,
526-
retry: 1,
527-
timeout: 50,
528-
}).text();
529-
t.is(text, 'ok');
530-
t.is(requestCount, 2);
526+
retry: {
527+
limit: 1,
528+
delay: () => 0,
529+
},
530+
timeout: 1000,
531+
}).text());
532+
t.is(error?.name, 'TimeoutError');
533+
t.is(requestCount, 1);
531534
t.true(Date.now() - start < 3000);
532535
});

0 commit comments

Comments
 (0)