Skip to content

Commit f7333f7

Browse files
committed
Harden logic
1 parent 1323b2e commit f7333f7

2 files changed

Lines changed: 155 additions & 9 deletions

File tree

source/core/Ky.ts

Lines changed: 48 additions & 9 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);
@@ -504,8 +505,15 @@ export class Ky {
504505
async #getResponseData(response: Response): Promise<unknown> {
505506
// Even with request timeouts disabled, bound error-body reads so retries and error propagation
506507
// cannot be stalled indefinitely by never-ending response streams.
507-
const errorDataTimeout = this.#options.timeout === false ? 10_000 : this.#options.timeout;
508-
const text = await this.#readResponseText(response, errorDataTimeout);
508+
const readTimeout = this.#getErrorDataTimeout();
509+
const text = await this.#readResponseText(response, readTimeout.timeout);
510+
if (text === timedOutResponseData) {
511+
if (readTimeout.totalTimeoutReachedOnTimeout) {
512+
throw new TimeoutError(this.request);
513+
}
514+
515+
return undefined;
516+
}
509517

510518
if (!text) {
511519
return undefined;
@@ -515,7 +523,38 @@ export class Ky {
515523
return text;
516524
}
517525

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

521560
#isJsonContentType(contentType: string): boolean {
@@ -524,7 +563,7 @@ export class Ky {
524563
return /\/(?:.*[.+-])?json$/.test(mimeType);
525564
}
526565

527-
async #readResponseText(response: Response, timeoutMs: number): Promise<string | undefined> {
566+
async #readResponseText(response: Response, timeoutMs: number): Promise<string | typeof timedOutResponseData | undefined> {
528567
const {body} = response;
529568
if (!body) {
530569
try {
@@ -571,17 +610,17 @@ export class Ky {
571610
return chunks.join('');
572611
})();
573612

574-
const timeoutPromise = new Promise<undefined>(resolve => {
613+
const timeoutPromise = new Promise<typeof timedOutResponseData>(resolve => {
575614
const timeoutId = setTimeout(() => {
576-
resolve(undefined);
615+
resolve(timedOutResponseData);
577616
}, timeoutMs);
578617
void readAll.finally(() => {
579618
clearTimeout(timeoutId);
580619
});
581620
});
582621

583622
const result = await Promise.race([readAll, timeoutPromise]);
584-
if (result === undefined) {
623+
if (result === timedOutResponseData) {
585624
void reader.cancel().catch(() => undefined);
586625
}
587626

@@ -593,9 +632,9 @@ export class Ky {
593632
try {
594633
return await Promise.race([
595634
Promise.resolve().then(() => (this.#options.parseJson ?? JSON.parse)(text)),
596-
new Promise<undefined>(resolve => {
635+
new Promise<typeof timedOutResponseData>(resolve => {
597636
timeoutId = setTimeout(() => {
598-
resolve(undefined);
637+
resolve(timedOutResponseData);
599638
}, timeoutMs);
600639
}),
601640
]);

test/retry.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1678,6 +1678,113 @@ test('totalTimeout with timeout: false - exceeds total budget', async t => {
16781678
t.is(requestCount, 1);
16791679
});
16801680

1681+
test('totalTimeout bounds hanging HTTPError body reads when timeout is disabled', async t => {
1682+
t.timeout(2000);
1683+
let requestCount = 0;
1684+
1685+
const customFetch: typeof fetch = async () => {
1686+
requestCount++;
1687+
1688+
const body = new ReadableStream<Uint8Array>({
1689+
start(controller) {
1690+
controller.enqueue(new TextEncoder().encode('{"error":"partial"'));
1691+
},
1692+
});
1693+
1694+
return new Response(body, {
1695+
status: 500,
1696+
headers: {'content-type': 'application/json'},
1697+
});
1698+
};
1699+
1700+
await t.throwsAsync(
1701+
ky('https://example.com', {
1702+
fetch: customFetch,
1703+
timeout: false,
1704+
totalTimeout: 50,
1705+
retry: {
1706+
limit: 5,
1707+
delay: () => 0,
1708+
},
1709+
}).text(),
1710+
{
1711+
name: 'TimeoutError',
1712+
},
1713+
);
1714+
1715+
t.is(requestCount, 1);
1716+
});
1717+
1718+
test('totalTimeout bounds hanging HTTPError body reads when timeout is larger', async t => {
1719+
t.timeout(2000);
1720+
let requestCount = 0;
1721+
1722+
const customFetch: typeof fetch = async () => {
1723+
requestCount++;
1724+
1725+
const body = new ReadableStream<Uint8Array>({
1726+
start(controller) {
1727+
controller.enqueue(new TextEncoder().encode('{"error":"partial"'));
1728+
},
1729+
});
1730+
1731+
return new Response(body, {
1732+
status: 500,
1733+
headers: {'content-type': 'application/json'},
1734+
});
1735+
};
1736+
1737+
await t.throwsAsync(
1738+
ky('https://example.com', {
1739+
fetch: customFetch,
1740+
timeout: 1000,
1741+
totalTimeout: 50,
1742+
retry: {
1743+
limit: 5,
1744+
delay: () => 0,
1745+
},
1746+
}).text(),
1747+
{
1748+
name: 'TimeoutError',
1749+
},
1750+
);
1751+
1752+
t.is(requestCount, 1);
1753+
});
1754+
1755+
test('totalTimeout bounds hanging HTTPError parseJson when timeout is disabled', async t => {
1756+
t.timeout(2000);
1757+
let requestCount = 0;
1758+
1759+
const customFetch: typeof fetch = async () => {
1760+
requestCount++;
1761+
return new Response('{"error":"parse-timeout"}', {
1762+
status: 500,
1763+
headers: {'content-type': 'application/json'},
1764+
});
1765+
};
1766+
1767+
await t.throwsAsync(
1768+
ky('https://example.com', {
1769+
fetch: customFetch,
1770+
timeout: false,
1771+
totalTimeout: 50,
1772+
parseJson: async () => new Promise<never>(() => {
1773+
// Intentionally never settles
1774+
}),
1775+
retry: {
1776+
limit: 5,
1777+
delay: () => 0,
1778+
},
1779+
}).text(),
1780+
{
1781+
name: 'TimeoutError',
1782+
},
1783+
);
1784+
1785+
t.is(requestCount, 1);
1786+
});
1787+
16811788
test('totalTimeout smaller than timeout - effective timeout is capped', async t => {
16821789
let requestCount = 0;
16831790

0 commit comments

Comments
 (0)