Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
# Changelog
All notable changes to this project will be documented in this file.

## 1.1.0
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

- `HttpError.from(response)` now aggregates `errors[]` entries from the response body into `err.message`, joined by `; `. For each entry, `message` is used if present, otherwise `detail` — covering both the [GraphQL specification](https://spec.graphql.org/October2021/#sec-Errors) (`message`) and [JSON:API](https://jsonapi.org/format/#errors) (`detail`) envelope shapes. The default `"${status} ${statusText}"` message is still used when the body has no `errors[]`, or when no entry has either field.
## [1.2.0] - 2026-05-26
### Added
- `HttpError.from()` accepts an optional second argument — an already-parsed JSON body. `from(response)` reads the body via `response.clone()` as before; `from(response, json)` uses the supplied body instead of re-reading the response, for a 200 response whose JSON you've already read (e.g. an `errors[]` envelope), since a response body can only be read once. The error keeps the response status and `cause`. Backward compatible.

## 1.0.0
## [1.1.0] - 2026-05-11
### Added
- `HttpError.from(response)` now aggregates `errors[]` entries from the response body into `err.message`, joined by `; `. For each entry, `message` is used if present, otherwise `detail` — covering both the [GraphQL specification](https://spec.graphql.org/October2021/#sec-Errors) (`message`) and [JSON:API](https://jsonapi.org/format/#errors) (`detail`) envelope shapes. The default `"${status} ${statusText}"` message is still used when the body has no `errors[]`, or when no entry has either field.

## [1.0.0] - 2026-02-11
### Added
- Initial release.
- `new HttpError(response)` — error with message `"${status} ${statusText}"` and `cause` set to the response.
- `HttpError.from(response)` — async factory that also captures `err.text` and `err.json`.
23 changes: 14 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,15 @@ if (!response.ok) {
const json = await response.json();

if (json?.errors?.length) {
throw await HttpError.from(response);
throw await HttpError.from(response, json);
}

return json;
```

The default `"${status} ${statusText}"` message is used when `from()` can't read the body (already consumed) or when the body has no `errors[]`.
Pass just the `Response` while its body is still unread (the non-ok case). Once you've read it with `response.json()`, pass that parsed body as the second argument — `from(response, json)` — so it isn't re-read (a response body can only be read once). Either way the error keeps the response status and `cause`.

The default `"${status} ${statusText}"` message is used when the body has no `errors[]`.

This covers two widely used envelope shapes:

Expand All @@ -75,14 +77,17 @@ This covers two widely used envelope shapes:

Creates an error with message `"${status} ${statusText}"` and sets `cause` to the response.

### `HttpError.from(response)`
### `HttpError.from(response, json)`

Async factory that creates an `HttpError` from a `Response` and captures the body:

Async factory that creates an `HttpError` and captures the response body:
- With **just a `Response`**, the body is read via `response.clone()` (the original is not consumed) and captured as `err.text` and `err.json`.
- With an **already-parsed `json`** as the second argument, that body is used directly (the response is not read), with `err.text` set to its JSON string — for a 200 response whose JSON you've already read.

- `err.text` — the response body as a string
- `err.json` — the parsed JSON (if the body is valid JSON)
- `err.cause` — the original `Response` object
In both cases:

The original response is not consumed (uses `response.clone()`).
- `err.text` — the body as a string
- `err.json` — the parsed JSON (if the body is/was valid JSON)
- `err.cause` — the original `Response`

If the parsed body carries an `errors[]` array, `err.message` is set to each entry's `message` or `detail` (whichever is present, in that order) joined by `; ` instead of the default `"${status} ${statusText}"`. See [APIs that return errors in the body](#apis-that-return-errors-in-the-body) above.
If the body carries an `errors[]` array, `err.message` is set to each entry's `message` or `detail` (whichever is present, in that order) joined by `; ` instead of the default `"${status} ${statusText}"`. See [APIs that return errors in the body](#apis-that-return-errors-in-the-body) above.
38 changes: 28 additions & 10 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,42 @@ class HttpError extends Error {
}

/**
* Create an HttpError from a fetch Response, capturing the response body as text and JSON.
* Create an HttpError from a fetch Response, optionally with an already-parsed body.
*
* Pass just the `Response` — e.g. for a non-ok status — and the body is captured via
* `response.clone()`, leaving the original intact. When you've already read the body — e.g.
* a 200 response with an `errors[]` envelope, read with `await response.json()` — pass it as
* the second argument so it isn't re-read (a `Response` body can only be read once). Either
* way the error keeps the response status and `cause`.
*
* @param {Response} response - The fetch Response object.
* @param {object} [json] - An already-parsed JSON body, used instead of reading the response.
* @returns {Promise<HttpError>} Error with text and json properties.
*/
static async from(response) {
static async from(response, json) {
const err = new HttpError(response);

try {
err.text = await response.clone().text();
} catch {
// Body already consumed or otherwise unreadable
}
if (json !== undefined) {
err.json = json;

if (err.text) {
try {
err.json = JSON.parse(err.text);
err.text = JSON.stringify(json);
} catch {
// Body is not serializable
}
} else {
try {
err.text = await response.clone().text();
} catch {
// Response body is not JSON
// Body already consumed or otherwise unreadable
}

if (err.text) {
try {
err.json = JSON.parse(err.text);
} catch {
// Response body is not JSON
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,5 @@
"test": "node --test --test-reporter=spec",
"test:only": "node --test --test-only --test-reporter=spec"
},
"version": "1.1.0"
"version": "1.2.0"
}
35 changes: 35 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,39 @@ test('HttpError', { concurrency: true }, async (t) => {
assert.strictEqual(err.text, undefined);
assert.strictEqual(err.json, undefined);
});

t.test('should use a body passed alongside the response without re-reading it', async () => {
const response = new Response('{"errors":[{"message":"from the response body"}]}', { status: 200, statusText: 'OK' });
const json = {
errors: [
{ code: 'SHIPMENT.CANCEL.FAILURE', message: 'Shipment already tendered' },
{ code: 'SERVICE.UNAVAILABLE', message: 'Service is currently unavailable' }
]
};
const err = await HttpError.from(response, json);

assert.strictEqual(err.name, 'HttpError');
assert.strictEqual(err.message, 'Shipment already tendered; Service is currently unavailable');
assert.deepStrictEqual(err.json, json);
assert.strictEqual(err.text, JSON.stringify(json));
assert.strictEqual(err.cause, response);
assert(err instanceof Error);
});

t.test('should aggregate JSON:API detail from a body passed alongside the response', async () => {
const response = new Response('', { status: 422, statusText: 'Unprocessable Entity' });
const json = { errors: [{ code: '422', detail: 'first name is required' }] };
const err = await HttpError.from(response, json);

assert.strictEqual(err.message, 'first name is required');
assert.deepStrictEqual(err.json, json);
});

t.test('should fall back to the status message when a passed body has no errors[]', async () => {
const response = new Response('', { status: 200, statusText: 'OK' });
const err = await HttpError.from(response, { ok: true });

assert.strictEqual(err.message, '200 OK');
assert.deepStrictEqual(err.json, { ok: true });
});
});