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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changelog

## 1.1.0

- `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

- 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`.
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,40 @@ try {
throw await HttpError.from(response);
}
} catch (err) {
console.error(err.message); // "404 Not Found"
console.error(err.message); // "404 Not Found" — or aggregated body errors[].message values
console.error(err.text); // Raw response body
console.error(err.json); // Parsed JSON (if applicable)
console.error(err.cause); // Original Response object
}
```

### APIs that return errors in the body

Some APIs carry application-level failures in the response body rather than (or in addition to) HTTP status codes. `from()` reads the body and aggregates an `errors[]` envelope into the message automatically — for each entry, the first present of `message` or `detail` is used, joined by `; `. Codes and any other per-error fields stay on `err.json.errors[]`.

```javascript
const response = await fetch('https://api.example.com/graphql', { /* ... */ });

if (!response.ok) {
throw await HttpError.from(response);
}

const json = await response.json();

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

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[]`.

This covers two widely used envelope shapes:

- [**GraphQL**](https://spec.graphql.org/October2021/#sec-Errors) — every response error entry includes a `message` string. Servers return 200 OK with a top-level `errors[]` for both partial and total failures. The same envelope is used by many REST APIs that signal application-level failures in the body rather than (or in addition to) HTTP status codes.
- [**JSON:API**](https://jsonapi.org/format/#errors) — error objects use `detail` for the per-occurrence explanation.

## API

### `new HttpError(response)`
Expand All @@ -57,3 +84,5 @@ Async factory that creates an `HttpError` and captures the response body:
- `err.cause` — the original `Response` object

The original response is not consumed (uses `response.clone()`).

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.
15 changes: 14 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ class HttpError extends Error {
*/
static async from(response) {
const err = new HttpError(response);
err.text = await response.clone().text().catch(() => {});

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

if (err.text) {
try {
Expand All @@ -28,6 +33,14 @@ class HttpError extends Error {
}
}

if (err.json?.errors?.length) {
const messages = err.json.errors.map(e => e.message ?? e.detail).filter(Boolean);

if (messages.length) {
err.message = messages.join('; ');
}
}

return err;
}
}
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.0.0"
"version": "1.1.0"
}
63 changes: 63 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,67 @@ test('HttpError', { concurrency: true }, async (t) => {

assert.strictEqual(text, 'body');
});

t.test('should aggregate body errors[] messages into err.message', async () => {
const body = {
errors: [
{ code: 'RATING.INVALID', message: 'Invalid account number' },
{ code: 'SERVICE.UNAVAILABLE', message: 'Service is currently unavailable' }
]
};
const response = new Response(JSON.stringify(body), { status: 200, statusText: 'OK' });
const err = await HttpError.from(response);

assert.strictEqual(err.message, 'Invalid account number; Service is currently unavailable');
assert.deepStrictEqual(err.json, body);
});

t.test('should leave message as status when body has no errors[]', async () => {
const response = new Response('{"foo":"bar"}', { status: 200, statusText: 'OK' });
const err = await HttpError.from(response);

assert.strictEqual(err.message, '200 OK');
});

t.test('should aggregate errors[] even on non-2xx responses', async () => {
const body = { errors: [{ message: 'Account locked' }] };
const response = new Response(JSON.stringify(body), { status: 403, statusText: 'Forbidden' });
const err = await HttpError.from(response);

assert.strictEqual(err.message, 'Account locked');
assert.deepStrictEqual(err.json, body);
});

t.test('should aggregate JSON:API-style errors[] using detail', async () => {
const body = {
errors: [
{ code: '422', detail: 'first name is required', title: 'Invalid Attribute' },
{ code: '422', detail: 'email is malformed', title: 'Invalid Attribute' }
]
};
const response = new Response(JSON.stringify(body), { status: 422, statusText: 'Unprocessable Entity' });
const err = await HttpError.from(response);

assert.strictEqual(err.message, 'first name is required; email is malformed');
});

t.test('should keep default status message when errors[] entries have neither message nor detail', async () => {
const body = { errors: [{ code: 'UNKNOWN', title: 'Service Unavailable' }] };
const response = new Response(JSON.stringify(body), { status: 500, statusText: 'Internal Server Error' });
const err = await HttpError.from(response);

assert.strictEqual(err.message, '500 Internal Server Error');
});

t.test('should not throw when the response body has already been consumed', async () => {
const response = new Response('{"errors":[{"message":"x"}]}', { status: 200, statusText: 'OK' });
await response.json();

const err = await HttpError.from(response);

assert.strictEqual(err.name, 'HttpError');
assert.strictEqual(err.message, '200 OK');
assert.strictEqual(err.text, undefined);
assert.strictEqual(err.json, undefined);
});
});