Skip to content

Commit 1341f5c

Browse files
authored
Add data property to HTTPError with pre-parsed response body (#823)
1 parent eb5c3eb commit 1341f5c

File tree

6 files changed

+765
-47
lines changed

6 files changed

+765
-47
lines changed

readme.md

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ The hook can return a [`Request`](https://developer.mozilla.org/en-US/docs/Web/A
381381

382382
The `retryCount` is always `>= 1` since this hook is only called during retries, not on the initial request.
383383

384-
If the request received a response, the error will be of type `HTTPError` and the `Response` object will be available at `error.response`. Be aware that some types of errors, such as network errors, inherently mean that a response was not received. In that case, the error will not be an instance of `HTTPError`.
384+
If the request received a response, the error will be of type `HTTPError`. The `Response` object will be available at `error.response`, and the pre-parsed response body will be available at `error.data`. Be aware that some types of errors, such as network errors, inherently mean that a response was not received. In that case, the error will not be an instance of `HTTPError`.
385385

386386
You can prevent Ky from retrying the request by throwing an error. Ky will not handle it in any way and the error will be propagated to the request initiator. The rest of the `beforeRetry` hooks will not be called in this case. Alternatively, you can return the [`ky.stop`](#kystop) symbol to do the same thing but without propagating an error (this has some limitations, see `ky.stop` docs for details).
387387

@@ -405,17 +405,21 @@ const response = await ky('https://example.com', {
405405
**Modifying the request URL:**
406406

407407
```js
408-
import ky from 'ky';
408+
import ky, {isHTTPError} from 'ky';
409409

410410
const response = await ky('https://example.com/api', {
411411
hooks: {
412412
beforeRetry: [
413-
async ({request, error}) => {
413+
({request, error}) => {
414414
// Add query parameters based on error response
415-
if (error.response) {
416-
const body = await error.response.json();
415+
if (
416+
isHTTPError(error)
417+
&& typeof error.data === 'object'
418+
&& error.data !== null
419+
&& 'processId' in error.data
420+
) {
417421
const url = new URL(request.url);
418-
url.searchParams.set('processId', body.processId);
422+
url.searchParams.set('processId', String(error.data.processId));
419423
return new Request(url, request);
420424
}
421425
}
@@ -458,12 +462,14 @@ import ky from 'ky';
458462
await ky('https://example.com', {
459463
hooks: {
460464
beforeError: [
461-
async error => {
462-
const {response} = error;
463-
if (response) {
464-
const body = await response.json();
465+
error => {
466+
if (
467+
typeof error.data === 'object'
468+
&& error.data !== null
469+
&& 'message' in error.data
470+
) {
465471
error.name = 'GitHubError';
466-
error.message = `${body.message} (${response.status})`;
472+
error.message = `${String(error.data.message)} (${error.response.status})`;
467473
}
468474

469475
return error;
@@ -1001,23 +1007,21 @@ const response = await api.get('https://example.com/api');
10011007
10021008
Exposed for `instanceof` checks. The error has a `response` property with the [`Response` object](https://developer.mozilla.org/en-US/docs/Web/API/Response), `request` property with the [`Request` object](https://developer.mozilla.org/en-US/docs/Web/API/Request), and `options` property with normalized options (either passed to `ky` when creating an instance with `ky.create()` or directly when performing the request).
10031009
1010+
It also has a `data` property with the pre-parsed response body. For JSON responses (based on `Content-Type`), the body is parsed using the [`parseJson` option](#parsejson) if set, or `JSON.parse` by default. For other content types, it is set as plain text. If the body is empty or parsing fails, `data` will be `undefined`. To avoid hanging or excessive buffering, `error.data` population is bounded by the request timeout and a 10 MiB response body size limit. The `data` property is populated before [`beforeError`](#hooks) hooks run, so hooks can access it.
1011+
10041012
Be aware that some types of errors, such as network errors, inherently mean that a response was not received. In that case, the error will not be an instance of HTTPError and will not contain a `response` property.
10051013
1006-
> [!IMPORTANT]
1007-
> When catching an `HTTPError`, you must consume or cancel the `error.response` body to prevent resource leaks (especially in Deno and Bun).
1014+
> [!NOTE]
1015+
> The response body is automatically consumed when populating `error.data`, so you do not need to manually consume or cancel `error.response.body`.
10081016
10091017
```js
1010-
import {isHTTPError} from 'ky';
1018+
import ky, {isHTTPError} from 'ky';
10111019

10121020
try {
10131021
await ky('https://example.com').json();
10141022
} catch (error) {
10151023
if (isHTTPError(error)) {
1016-
// Option 1: Read the error response body
1017-
const errorJson = await error.response.json();
1018-
1019-
// Option 2: Cancel the body if you don't need it
1020-
// await error.response.body?.cancel();
1024+
console.log(error.data);
10211025
}
10221026
}
10231027
```
@@ -1028,10 +1032,9 @@ You can also use the `beforeError` hook:
10281032
await ky('https://example.com', {
10291033
hooks: {
10301034
beforeError: [
1031-
async error => {
1032-
const {response} = error;
1033-
if (response) {
1034-
error.message = `${error.message}: ${await response.text()}`;
1035+
error => {
1036+
if (error.data !== undefined) {
1037+
error.message = `${error.message}: ${JSON.stringify(error.data)}`;
10351038
}
10361039

10371040
return error;
@@ -1041,7 +1044,7 @@ await ky('https://example.com', {
10411044
});
10421045
```
10431046
1044-
⌨️ **TypeScript:** Accepts an optional [type parameter](https://www.typescriptlang.org/docs/handbook/2/generics.html), which defaults to [`unknown`](https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown), and is passed through to the return type of `error.response.json()`.
1047+
⌨️ **TypeScript:** Accepts an optional [type parameter](https://www.typescriptlang.org/docs/handbook/2/generics.html), which defaults to [`unknown`](https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown), and is passed through to the type of `error.data`.
10451048
10461049
### TimeoutError
10471050

source/core/Ky.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ import {
3030
supportsRequestStreams,
3131
} from './constants.js';
3232

33+
const maxErrorResponseBodySize = 10 * 1024 * 1024;
34+
35+
const createTextDecoder = (contentType: string): TextDecoder => {
36+
const match = /;\s*charset\s*=\s*(?:"([^"]+)"|([^;,\s]+))/i.exec(contentType);
37+
const charset = match?.[1] ?? match?.[2];
38+
if (charset) {
39+
try {
40+
return new TextDecoder(charset);
41+
} catch {}
42+
}
43+
44+
return new TextDecoder();
45+
};
46+
3347
export class Ky {
3448
static create(input: Input, options: Options): ResponsePromise {
3549
const ky = new Ky(input, options);
@@ -97,6 +111,7 @@ export class Ky {
97111
: ky.#options.throwHttpErrors
98112
)) {
99113
let error = new HTTPError(response, ky.request, ky.#getNormalizedOptions());
114+
error.data = await ky.#getResponseData(response);
100115

101116
for (const hook of ky.#options.hooks.beforeError) {
102117
// eslint-disable-next-line no-await-in-loop
@@ -395,6 +410,111 @@ export class Ky {
395410
return response;
396411
}
397412

413+
async #getResponseData(response: Response): Promise<unknown> {
414+
// Even with request timeouts disabled, bound error-body reads so retries and error propagation
415+
// cannot be stalled indefinitely by never-ending response streams.
416+
const errorDataTimeout = this.#options.timeout === false ? 10_000 : this.#options.timeout;
417+
const text = await this.#readResponseText(response, errorDataTimeout);
418+
419+
if (!text) {
420+
return undefined;
421+
}
422+
423+
if (!this.#isJsonContentType(response.headers.get('content-type') ?? '')) {
424+
return text;
425+
}
426+
427+
return this.#parseJson(text, errorDataTimeout);
428+
}
429+
430+
#isJsonContentType(contentType: string): boolean {
431+
// Match JSON subtypes like `json`, `problem+json`, and `vnd.api+json`.
432+
const mimeType = (contentType.split(';', 1)[0] ?? '').trim().toLowerCase();
433+
return /\/(?:.*[.+-])?json$/.test(mimeType);
434+
}
435+
436+
async #readResponseText(response: Response, timeoutMs: number): Promise<string | undefined> {
437+
const {body} = response;
438+
if (!body) {
439+
try {
440+
return await response.text();
441+
} catch {
442+
return undefined;
443+
}
444+
}
445+
446+
let reader: ReadableStreamDefaultReader<Uint8Array>;
447+
try {
448+
reader = body.getReader();
449+
} catch {
450+
// Another consumer already locked the stream.
451+
return undefined;
452+
}
453+
454+
const decoder = createTextDecoder(response.headers.get('content-type') ?? '');
455+
const chunks: string[] = [];
456+
let totalBytes = 0;
457+
458+
const readAll = (async (): Promise<string | undefined> => {
459+
try {
460+
for (;;) {
461+
// eslint-disable-next-line no-await-in-loop
462+
const {done, value} = await reader.read();
463+
if (done) {
464+
break;
465+
}
466+
467+
totalBytes += value.byteLength;
468+
if (totalBytes > maxErrorResponseBodySize) {
469+
void reader.cancel().catch(() => undefined);
470+
return undefined;
471+
}
472+
473+
chunks.push(decoder.decode(value, {stream: true}));
474+
}
475+
} catch {
476+
return undefined;
477+
}
478+
479+
chunks.push(decoder.decode());
480+
return chunks.join('');
481+
})();
482+
483+
const timeoutPromise = new Promise<undefined>(resolve => {
484+
const timeoutId = setTimeout(() => {
485+
resolve(undefined);
486+
}, timeoutMs);
487+
void readAll.finally(() => {
488+
clearTimeout(timeoutId);
489+
});
490+
});
491+
492+
const result = await Promise.race([readAll, timeoutPromise]);
493+
if (result === undefined) {
494+
void reader.cancel().catch(() => undefined);
495+
}
496+
497+
return result;
498+
}
499+
500+
async #parseJson(text: string, timeoutMs: number): Promise<unknown> {
501+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
502+
try {
503+
return await Promise.race([
504+
Promise.resolve().then(() => (this.#options.parseJson ?? JSON.parse)(text)),
505+
new Promise<undefined>(resolve => {
506+
timeoutId = setTimeout(() => {
507+
resolve(undefined);
508+
}, timeoutMs);
509+
}),
510+
]);
511+
} catch {
512+
return undefined;
513+
} finally {
514+
clearTimeout(timeoutId);
515+
}
516+
}
517+
398518
#cancelBody(body: ReadableStream | undefined): void {
399519
if (!body) {
400520
return;

source/errors/HTTPError.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export class HTTPError<T = unknown> extends Error {
66
public response: KyResponse<T>;
77
public request: KyRequest;
88
public options: NormalizedOptions;
9+
public data: T | string | undefined;
910

1011
constructor(response: Response, request: Request, options: NormalizedOptions) {
1112
const code = (response.status || response.status === 0) ? response.status : '';

source/types/hooks.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export type Hooks = {
9292
9393
The hook can return a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) to replace the outgoing retry request, or return a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) to skip the retry and use that response instead. **Note:** Returning a request or response skips remaining `beforeRetry` hooks.
9494
95-
If the request received a response, the error will be of type `HTTPError` and the `Response` object will be available at `error.response`. Be aware that some types of errors, such as network errors, inherently mean that a response was not received. In that case, the error will not be an instance of `HTTPError`.
95+
If the request received a response, the error will be of type `HTTPError`. The `Response` object will be available at `error.response`, and the pre-parsed response body will be available at `error.data`. Be aware that some types of errors, such as network errors, inherently mean that a response was not received. In that case, the error will not be an instance of `HTTPError`.
9696
9797
You can prevent Ky from retrying the request by throwing an error. Ky will not handle it in any way and the error will be propagated to the request initiator. The rest of the `beforeRetry` hooks will not be called in this case. Alternatively, you can return the [`ky.stop`](#ky.stop) symbol to do the same thing but without propagating an error (this has some limitations, see `ky.stop` docs for details).
9898
@@ -118,17 +118,21 @@ export type Hooks = {
118118
119119
@example
120120
```
121-
import ky from 'ky';
121+
import ky, {isHTTPError} from 'ky';
122122
123123
const response = await ky('https://example.com/api', {
124124
hooks: {
125125
beforeRetry: [
126-
async ({request, error}) => {
126+
({request, error}) => {
127127
// Add query parameters based on error response
128-
if (error.response) {
129-
const body = await error.response.json();
128+
if (
129+
isHTTPError(error)
130+
&& typeof error.data === 'object'
131+
&& error.data !== null
132+
&& 'processId' in error.data
133+
) {
130134
const url = new URL(request.url);
131-
url.searchParams.set('processId', body.processId);
135+
url.searchParams.set('processId', String(error.data.processId));
132136
return new Request(url, request);
133137
}
134138
}
@@ -240,12 +244,14 @@ export type Hooks = {
240244
await ky('https://example.com', {
241245
hooks: {
242246
beforeError: [
243-
async error => {
244-
const {response} = error;
245-
if (response) {
246-
const body = await response.json();
247+
error => {
248+
if (
249+
typeof error.data === 'object'
250+
&& error.data !== null
251+
&& 'message' in error.data
252+
) {
247253
error.name = 'GitHubError';
248-
error.message = `${body.message} (${response.status})`;
254+
error.message = `${String(error.data.message)} (${error.response.status})`;
249255
}
250256
251257
return error;

0 commit comments

Comments
 (0)