Skip to content
Closed
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
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,27 @@ try {

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` and captures the response body:

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

The original response is not consumed (uses `response.clone()`).
When called with just a `response`, reads the body via `response.clone().text()` so the original response is not consumed.

When the caller has already consumed the body (via `response.json()`), pass the parsed body as the second argument to skip the body read. Useful for application-level error envelopes on otherwise-ok responses (GraphQL `data.errors[]`, REST 200-with-`errors[]`, etc.):

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

if (json.errors?.length) {
const err = await HttpError.from(response, json);
err.message = json.errors.map(e => e.message).join('; ');
throw err;
}
```

In that mode, `.json` is set to the supplied body, `.text` to `JSON.stringify(json)`, and the message defaults to `"${status} ${statusText}"` — override it after construction if you want a different one.
13 changes: 12 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,22 @@ class HttpError extends Error {

/**
* Create an HttpError from a fetch Response, capturing the response body as text and JSON.
* Pass `json` when the caller has already consumed the body via `response.json()` — useful
* for application-level error envelopes on otherwise-ok responses (GraphQL data.errors[],
* REST 200 with errors[], etc.) — to skip the body read.
* @param {Response} response - The fetch Response object.
* @param {*} [json] - The already-parsed JSON body, if the caller has it.
* @returns {Promise<HttpError>} Error with text and json properties.
*/
static async from(response) {
static async from(response, json) {
const err = new HttpError(response);

if (json !== undefined) {
err.json = json;
err.text = JSON.stringify(json);
return err;
}

err.text = await response.clone().text().catch(() => {});

if (err.text) {
Expand Down
22 changes: 22 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,26 @@ test('HttpError', { concurrency: true }, async (t) => {

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

t.test('should use a pre-parsed JSON body when passed as the second argument', async () => {
const response = new Response('', { status: 200, statusText: 'OK' });
const json = { errors: [{ message: 'Invalid account number' }] };
const err = await HttpError.from(response, json);

assert.strictEqual(err.name, 'HttpError');
assert.strictEqual(err.message, '200 OK');
assert.strictEqual(err.cause, response);
assert.deepStrictEqual(err.json, json);
assert.strictEqual(err.text, '{"errors":[{"message":"Invalid account number"}]}');
});

t.test('should let the caller override the message after construction', async () => {
const response = new Response('', { status: 200, statusText: 'OK' });
const json = { errors: [{ message: 'Invalid account number' }] };
const err = await HttpError.from(response, json);
err.message = json.errors.map(e => e.message).join('; ');

assert.strictEqual(err.message, 'Invalid account number');
assert.deepStrictEqual(err.json, json);
});
});