From 13e1f48841bc79a9556d539530a6c1569eae1afb Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Mon, 11 May 2026 19:50:42 -0500 Subject: [PATCH 01/12] aggregate body errors[] messages into err.message When a response body carries an errors[] envelope (GraphQL data.errors[], REST errors[], etc.), surface every errors[].message joined by '; ' as err.message instead of the default '${status} ${statusText}'. Codes and any other per-error fields stay accessible via err.json.errors[]. Aggregation runs regardless of HTTP status, so a 200-with-errors response and a 4xx-with-errors response both produce a fully populated HttpError when passed to from(). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 ++ index.js | 4 ++++ test/index.js | 30 ++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/README.md b/README.md index edc58af..464d26b 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,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[]` envelope (GraphQL `data.errors[]`, REST `errors[]`, etc.), `err.message` is set to every `errors[].message` joined by `; ` instead of the default `"${status} ${statusText}"`. The codes and any other per-error fields stay accessible via `err.json.errors[]`. diff --git a/index.js b/index.js index 7dfd1ea..160dbaf 100644 --- a/index.js +++ b/index.js @@ -28,6 +28,10 @@ class HttpError extends Error { } } + if (err.json?.errors?.length) { + err.message = err.json.errors.map(e => e.message).join('; '); + } + return err; } } diff --git a/test/index.js b/test/index.js index 387da49..01f056f 100644 --- a/test/index.js +++ b/test/index.js @@ -49,4 +49,34 @@ 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); + }); }); From 1d831c949a02eb5b629ea93557421a2b36c306e3 Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Mon, 11 May 2026 20:12:17 -0500 Subject: [PATCH 02/12] bump to 1.1.0, add changelog, document 200-with-errors flow in README Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 11 +++++++++++ README.md | 21 +++++++++++++++++++-- package.json | 2 +- 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..754f4d3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 1.1.0 + +- `HttpError.from(response)` now aggregates `errors[].message` entries from the response body into `err.message`, joined by `; `. This matches the GraphQL/JSON:API/REST-with-errors convention used by several APIs that signal application-level failures via a body envelope rather than (or in addition to) HTTP status codes. The default `"${status} ${statusText}"` message is still used when the body has no `errors[]`. + +## 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`. diff --git a/README.md b/README.md index 464d26b..d068fc3 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,30 @@ 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 200 with an `errors[]` envelope + +GraphQL servers (always 200, errors in `data.errors[]`), JSON:API, and some REST APIs (FedEx, etc.) carry application-level failures in the response body instead of relying on HTTP status codes. `from()` reads the body and aggregates the messages automatically — call it before consuming the body yourself so the internal `response.clone()` can read it: + +```javascript +const response = await fetch('https://api.example.com/graphql', { /* ... */ }); +const err = await HttpError.from(response); + +if (!response.ok || err.json?.errors?.length) { + throw err; +} + +return err.json; // success: use the body parsed by from() +``` + +`err.message` is every `errors[].message` joined by `; `. Codes and other per-error fields stay on `err.json.errors[]`. + ## API ### `new HttpError(response)` @@ -58,4 +75,4 @@ Async factory that creates an `HttpError` and captures the response body: The original response is not consumed (uses `response.clone()`). -If the parsed body carries an `errors[]` envelope (GraphQL `data.errors[]`, REST `errors[]`, etc.), `err.message` is set to every `errors[].message` joined by `; ` instead of the default `"${status} ${statusText}"`. The codes and any other per-error fields stay accessible via `err.json.errors[]`. +If the parsed body carries an `errors[]` envelope, `err.message` is set to every `errors[].message` joined by `; ` instead of the default `"${status} ${statusText}"`. diff --git a/package.json b/package.json index 7ba262d..aa798f3 100644 --- a/package.json +++ b/package.json @@ -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" } From 07858898e142864055ef8e79fe0acd20c901fdc3 Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Mon, 11 May 2026 20:15:17 -0500 Subject: [PATCH 03/12] remove vendor-specific reference, cite only canonical specs Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- README.md | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 754f4d3..ffd87a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.1.0 -- `HttpError.from(response)` now aggregates `errors[].message` entries from the response body into `err.message`, joined by `; `. This matches the GraphQL/JSON:API/REST-with-errors convention used by several APIs that signal application-level failures via a body envelope rather than (or in addition to) HTTP status codes. The default `"${status} ${statusText}"` message is still used when the body has no `errors[]`. +- `HttpError.from(response)` now aggregates `errors[].message` entries from the response body into `err.message`, joined by `; `. This matches the [GraphQL specification](https://spec.graphql.org/October2021/#sec-Errors) (errors in `data.errors[]` on 200 OK) and other APIs that follow the same body envelope. The default `"${status} ${statusText}"` message is still used when the body has no `errors[]`. [JSON:API](https://jsonapi.org/format/#errors) and [RFC 9457 Problem Details](https://datatracker.ietf.org/doc/html/rfc9457) define structurally similar envelopes but use `detail`/`title` instead of `message`; callers using those shapes should override `err.message` after construction. ## 1.0.0 diff --git a/README.md b/README.md index d068fc3..f5b742e 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,9 @@ try { } ``` -### APIs that return 200 with an `errors[]` envelope +### APIs that return errors in the body -GraphQL servers (always 200, errors in `data.errors[]`), JSON:API, and some REST APIs (FedEx, etc.) carry application-level failures in the response body instead of relying on HTTP status codes. `from()` reads the body and aggregates the messages automatically — call it before consuming the body yourself so the internal `response.clone()` can read it: +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 automatically — call it before consuming the body yourself so the internal `response.clone()` can read it: ```javascript const response = await fetch('https://api.example.com/graphql', { /* ... */ }); @@ -59,6 +59,10 @@ return err.json; // success: use the body parsed by from() `err.message` is every `errors[].message` joined by `; `. Codes and other per-error fields stay on `err.json.errors[]`. +This matches the convention defined by the [**GraphQL specification**](https://spec.graphql.org/October2021/#sec-Errors): every response error entry includes a `message` string, and servers return 200 OK with `data.errors[]` for both partial and total failures. The same envelope shape (`errors[].message`) is used by many REST APIs that signal application-level failures in the body rather than via HTTP status codes. + +[**JSON:API**](https://jsonapi.org/format/#errors) and [**RFC 9457 Problem Details**](https://datatracker.ietf.org/doc/html/rfc9457) define structurally similar error envelopes but use `detail` / `title` instead of `message`. Aggregation will still run for those (entries without `.message` join as `undefined`), so callers using those shapes should override `err.message` after construction. + ## API ### `new HttpError(response)` @@ -75,4 +79,4 @@ Async factory that creates an `HttpError` and captures the response body: The original response is not consumed (uses `response.clone()`). -If the parsed body carries an `errors[]` envelope, `err.message` is set to every `errors[].message` joined by `; ` instead of the default `"${status} ${statusText}"`. +If the parsed body carries an `errors[]` array, `err.message` is set to every `errors[].message` joined by `; ` instead of the default `"${status} ${statusText}"`. See [APIs that return errors in the body](#apis-that-return-errors-in-the-body) above. From 33b1f55281eea7bda853f5c67f1218f09552a025 Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Mon, 11 May 2026 20:16:45 -0500 Subject: [PATCH 04/12] drop implementation-detail aside from README example Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f5b742e..a453eea 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ try { ### 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 automatically — call it before consuming the body yourself so the internal `response.clone()` can read it: +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 automatically: ```javascript const response = await fetch('https://api.example.com/graphql', { /* ... */ }); From 8465e42cb2cb469dd8d37899a89be4fc8c66b277 Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Mon, 11 May 2026 20:18:15 -0500 Subject: [PATCH 05/12] fix README example: clone-before-consume, only call from() on error path Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a453eea..ab8d2c7 100644 --- a/README.md +++ b/README.md @@ -44,17 +44,17 @@ try { ### 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 automatically: +Some APIs carry application-level failures in the response body rather than (or in addition to) HTTP status codes. Inspect a clone of the body, then hand the untouched response to `from()` if you need to throw — `from()` reads the body and aggregates an `errors[]` envelope into the message automatically: ```javascript const response = await fetch('https://api.example.com/graphql', { /* ... */ }); -const err = await HttpError.from(response); +const json = await response.clone().json(); -if (!response.ok || err.json?.errors?.length) { - throw err; +if (!response.ok || json.errors?.length) { + throw await HttpError.from(response); } -return err.json; // success: use the body parsed by from() +return json; ``` `err.message` is every `errors[].message` joined by `; `. Codes and other per-error fields stay on `err.json.errors[]`. From 6e73d3b0ab523a9d8d9a35c0b0052906b39c0aca Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Mon, 11 May 2026 20:19:40 -0500 Subject: [PATCH 06/12] fall through message -> detail -> title to cover GraphQL and JSON:API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GraphQL's spec mandates errors[].message; JSON:API uses errors[].detail with title as a short summary. Both are widely used 'errors[]' envelope shapes, so picking the first present field across the three keeps the aggregation useful for either without forcing callers to override the message themselves. Drops the RFC 9457 reference — Problem Details is a top-level object, not a nested errors[] envelope, so the aggregation block doesn't apply. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- README.md | 9 +++++---- index.js | 6 +++++- test/index.js | 29 +++++++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffd87a9..ba1eb44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.1.0 -- `HttpError.from(response)` now aggregates `errors[].message` entries from the response body into `err.message`, joined by `; `. This matches the [GraphQL specification](https://spec.graphql.org/October2021/#sec-Errors) (errors in `data.errors[]` on 200 OK) and other APIs that follow the same body envelope. The default `"${status} ${statusText}"` message is still used when the body has no `errors[]`. [JSON:API](https://jsonapi.org/format/#errors) and [RFC 9457 Problem Details](https://datatracker.ietf.org/doc/html/rfc9457) define structurally similar envelopes but use `detail`/`title` instead of `message`; callers using those shapes should override `err.message` after construction. +- `HttpError.from(response)` now aggregates `errors[]` entries from the response body into `err.message`, joined by `; `. For each entry, the first present of `message`, `detail`, or `title` is used — covering both the [GraphQL specification](https://spec.graphql.org/October2021/#sec-Errors) (`message`) and [JSON:API](https://jsonapi.org/format/#errors) (`detail` / `title`) envelope shapes. The default `"${status} ${statusText}"` message is still used when the body has no `errors[]`, or when none of the entries have any of those three fields. ## 1.0.0 diff --git a/README.md b/README.md index ab8d2c7..972e226 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,12 @@ if (!response.ok || json.errors?.length) { return json; ``` -`err.message` is every `errors[].message` joined by `; `. Codes and other per-error fields stay on `err.json.errors[]`. +`err.message` is the joined human-readable text from each entry — `message`, falling back to `detail`, then `title`. Codes and other per-error fields stay on `err.json.errors[]`. -This matches the convention defined by the [**GraphQL specification**](https://spec.graphql.org/October2021/#sec-Errors): every response error entry includes a `message` string, and servers return 200 OK with `data.errors[]` for both partial and total failures. The same envelope shape (`errors[].message`) is used by many REST APIs that signal application-level failures in the body rather than via HTTP status codes. +This covers two widely used envelope shapes: -[**JSON:API**](https://jsonapi.org/format/#errors) and [**RFC 9457 Problem Details**](https://datatracker.ietf.org/doc/html/rfc9457) define structurally similar error envelopes but use `detail` / `title` instead of `message`. Aggregation will still run for those (entries without `.message` join as `undefined`), so callers using those shapes should override `err.message` after construction. +- [**GraphQL**](https://spec.graphql.org/October2021/#sec-Errors) — every response error entry includes a `message` string, and servers return 200 OK with `data.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, with `title` as a short human-readable summary. ## API @@ -79,4 +80,4 @@ Async factory that creates an `HttpError` and captures the response body: The original response is not consumed (uses `response.clone()`). -If the parsed body carries an `errors[]` array, `err.message` is set to every `errors[].message` 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 parsed body carries an `errors[]` array, `err.message` is set to each entry's `message` / `detail` / `title` (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. diff --git a/index.js b/index.js index 160dbaf..e748f95 100644 --- a/index.js +++ b/index.js @@ -29,7 +29,11 @@ class HttpError extends Error { } if (err.json?.errors?.length) { - err.message = err.json.errors.map(e => e.message).join('; '); + const messages = err.json.errors.map(e => e.message ?? e.detail ?? e.title).filter(Boolean); + + if (messages.length) { + err.message = messages.join('; '); + } } return err; diff --git a/test/index.js b/test/index.js index 01f056f..d758367 100644 --- a/test/index.js +++ b/test/index.js @@ -79,4 +79,33 @@ test('HttpError', { concurrency: true }, async (t) => { 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 fall back to title when neither message nor detail is present', async () => { + const body = { errors: [{ title: 'Service Unavailable' }] }; + const response = new Response(JSON.stringify(body), { status: 503, statusText: 'Service Unavailable' }); + const err = await HttpError.from(response); + + assert.strictEqual(err.message, 'Service Unavailable'); + }); + + t.test('should keep default status message when errors[] entries have none of message/detail/title', async () => { + const body = { errors: [{ code: 'UNKNOWN' }] }; + 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'); + }); }); From f2e99f21814a03a0e14a6394f0fdb3ec0fe64374 Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Mon, 11 May 2026 20:20:39 -0500 Subject: [PATCH 07/12] fix README: GraphQL errors[] is top-level, not data.errors[] Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 972e226..b844f21 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ return json; This covers two widely used envelope shapes: -- [**GraphQL**](https://spec.graphql.org/October2021/#sec-Errors) — every response error entry includes a `message` string, and servers return 200 OK with `data.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. +- [**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[]` (alongside `data`) 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, with `title` as a short human-readable summary. ## API From 737c0c3677e0ee710daf66b8fd97ca2983e0aeaf Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Mon, 11 May 2026 20:22:11 -0500 Subject: [PATCH 08/12] drop title from aggregation fallback JSON:API title is a category/summary that shouldn't change between occurrences, so it's a weak per-instance message. Sticking with message and detail gives clean 2-for-2 coverage of GraphQL and JSON:API. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- README.md | 6 +++--- index.js | 2 +- test/index.js | 12 ++---------- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba1eb44..cca91e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.1.0 -- `HttpError.from(response)` now aggregates `errors[]` entries from the response body into `err.message`, joined by `; `. For each entry, the first present of `message`, `detail`, or `title` is used — covering both the [GraphQL specification](https://spec.graphql.org/October2021/#sec-Errors) (`message`) and [JSON:API](https://jsonapi.org/format/#errors) (`detail` / `title`) envelope shapes. The default `"${status} ${statusText}"` message is still used when the body has no `errors[]`, or when none of the entries have any of those three fields. +- `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 diff --git a/README.md b/README.md index b844f21..5c5b2c3 100644 --- a/README.md +++ b/README.md @@ -57,12 +57,12 @@ if (!response.ok || json.errors?.length) { return json; ``` -`err.message` is the joined human-readable text from each entry — `message`, falling back to `detail`, then `title`. Codes and other per-error fields stay on `err.json.errors[]`. +`err.message` is the joined human-readable text from each entry — `message`, falling back to `detail`. Codes and other per-error fields stay on `err.json.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[]` (alongside `data`) 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, with `title` as a short human-readable summary. +- [**JSON:API**](https://jsonapi.org/format/#errors) — error objects use `detail` for the per-occurrence explanation. ## API @@ -80,4 +80,4 @@ Async factory that creates an `HttpError` and captures the response body: 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` / `detail` / `title` (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 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. diff --git a/index.js b/index.js index e748f95..553dc25 100644 --- a/index.js +++ b/index.js @@ -29,7 +29,7 @@ class HttpError extends Error { } if (err.json?.errors?.length) { - const messages = err.json.errors.map(e => e.message ?? e.detail ?? e.title).filter(Boolean); + const messages = err.json.errors.map(e => e.message ?? e.detail).filter(Boolean); if (messages.length) { err.message = messages.join('; '); diff --git a/test/index.js b/test/index.js index d758367..70fe85d 100644 --- a/test/index.js +++ b/test/index.js @@ -93,16 +93,8 @@ test('HttpError', { concurrency: true }, async (t) => { assert.strictEqual(err.message, 'first name is required; email is malformed'); }); - t.test('should fall back to title when neither message nor detail is present', async () => { - const body = { errors: [{ title: 'Service Unavailable' }] }; - const response = new Response(JSON.stringify(body), { status: 503, statusText: 'Service Unavailable' }); - const err = await HttpError.from(response); - - assert.strictEqual(err.message, 'Service Unavailable'); - }); - - t.test('should keep default status message when errors[] entries have none of message/detail/title', async () => { - const body = { errors: [{ code: 'UNKNOWN' }] }; + 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); From 161e6f91c43bfbefb05bbf5f87d647df122c3c16 Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Mon, 11 May 2026 20:24:30 -0500 Subject: [PATCH 09/12] drop prescriptive wording about when callers should throw Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c5b2c3..4a33ea0 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ try { ### 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. Inspect a clone of the body, then hand the untouched response to `from()` if you need to throw — `from()` reads the body and aggregates an `errors[]` envelope into the message automatically: +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: ```javascript const response = await fetch('https://api.example.com/graphql', { /* ... */ }); From 3825e0425c3acc90b1ecd191f9cbbbe882de9b46 Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Mon, 11 May 2026 20:24:57 -0500 Subject: [PATCH 10/12] drop code block from errors-in-body section; describe behavior in prose Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/README.md b/README.md index 4a33ea0..8f50dfd 100644 --- a/README.md +++ b/README.md @@ -44,20 +44,7 @@ try { ### 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: - -```javascript -const response = await fetch('https://api.example.com/graphql', { /* ... */ }); -const json = await response.clone().json(); - -if (!response.ok || json.errors?.length) { - throw await HttpError.from(response); -} - -return json; -``` - -`err.message` is the joined human-readable text from each entry — `message`, falling back to `detail`. Codes and other per-error fields stay on `err.json.errors[]`. +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[]`. The default `"${status} ${statusText}"` message is preserved when the body has no `errors[]`. This covers two widely used envelope shapes: From 967f9b8257fc54390abfe4777415575d9df76048 Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Mon, 11 May 2026 20:28:22 -0500 Subject: [PATCH 11/12] make from() resilient to consumed bodies, restore example with throw pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit response.clone() throws synchronously when the body has been disturbed, so wrap the body read in try/catch. After a caller does response.json() and then needs to throw on an in-body errors envelope, calling from() again no longer crashes — it returns a baseline HttpError with the status message. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 20 +++++++++++++++++++- index.js | 7 ++++++- test/index.js | 12 ++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8f50dfd..ad14bfc 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,25 @@ try { ### 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[]`. The default `"${status} ${statusText}"` message is preserved when the body has no `errors[]`. +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: diff --git a/index.js b/index.js index 553dc25..7f6d6a5 100644 --- a/index.js +++ b/index.js @@ -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 { diff --git a/test/index.js b/test/index.js index 70fe85d..cbc2293 100644 --- a/test/index.js +++ b/test/index.js @@ -100,4 +100,16 @@ test('HttpError', { concurrency: true }, async (t) => { 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); + }); }); From 54e5187c26dbdbe1b5138351852027e5c9b5b57f Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Mon, 11 May 2026 20:40:27 -0500 Subject: [PATCH 12/12] drop redundant parenthetical from GraphQL bullet Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ad14bfc..751ece7 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ The default `"${status} ${statusText}"` message is used when `from()` can't read 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[]` (alongside `data`) 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. +- [**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