Skip to content

Commit d96ff8a

Browse files
committed
Harden logic
1 parent f499740 commit d96ff8a

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

511519
if (!text) {
512520
return undefined;
@@ -516,7 +524,38 @@ export class Ky {
516524
return text;
517525
}
518526

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

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

528-
async #readResponseText(response: Response, timeoutMs: number): Promise<string | undefined> {
567+
async #readResponseText(response: Response, timeoutMs: number): Promise<string | typeof timedOutResponseData | undefined> {
529568
const {body} = response;
530569
if (!body) {
531570
try {
@@ -572,17 +611,17 @@ export class Ky {
572611
return chunks.join('');
573612
})();
574613

575-
const timeoutPromise = new Promise<undefined>(resolve => {
614+
const timeoutPromise = new Promise<typeof timedOutResponseData>(resolve => {
576615
const timeoutId = setTimeout(() => {
577-
resolve(undefined);
616+
resolve(timedOutResponseData);
578617
}, timeoutMs);
579618
void readAll.finally(() => {
580619
clearTimeout(timeoutId);
581620
});
582621
});
583622

584623
const result = await Promise.race([readAll, timeoutPromise]);
585-
if (result === undefined) {
624+
if (result === timedOutResponseData) {
586625
void reader.cancel().catch(() => undefined);
587626
}
588627

@@ -597,9 +636,9 @@ export class Ky {
597636
? this.#options.parseJson(text, {request: this.request, response})
598637
: JSON.parse(text),
599638
),
600-
new Promise<undefined>(resolve => {
639+
new Promise<typeof timedOutResponseData>(resolve => {
601640
timeoutId = setTimeout(() => {
602-
resolve(undefined);
641+
resolve(timedOutResponseData);
603642
}, timeoutMs);
604643
}),
605644
]);

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)