From d7c4c3397001bb5a63bd30c5b5990fb1395f4916 Mon Sep 17 00:00:00 2001 From: Sam Vargas Date: Thu, 15 Oct 2026 09:40:32 +0100 Subject: [PATCH 1/7] [BY-3684]: throw body read errors instead of resolving undefined A 2xx response whose body failed to download or parse was logged and swallowed, so send() resolved undefined and callers crashed on destructure (MARKETING-APP-K). Surface it as a BodyReadError carrying the underlying cause, so request-level and global error handlers run like they do for every other failure. --- lib/index.js | 21 +++++++++-- lib/index.spec.js | 88 +++++++++++++++++++++++++++++++++++++++++------ lib/types.ts | 2 ++ 3 files changed, 98 insertions(+), 13 deletions(-) diff --git a/lib/index.js b/lib/index.js index 7ba8cd9..9f06f07 100644 --- a/lib/index.js +++ b/lib/index.js @@ -96,6 +96,24 @@ function getErrorByCode (code) { return errorMapping[code] || HttpError } +/** + * Error thrown when a successful (2xx/3xx) response body could not be + * downloaded or parsed. The request reached the server and response headers + * were received, so the response is incomplete rather than absent. + */ +export class BodyReadError extends HttpError { + /** + * Create a new body read error + * @param {unknown} cause - The error thrown while reading the response body + */ + constructor (cause) { + super(cause instanceof Error ? cause.message : String(cause)) + this.cause = cause + /** @type {string | undefined} Error code of the underlying cause, when present */ + this.code = cause && /** @type {{ code?: string }} */ (cause).code + } +} + /** * @param {ApiOptions['retry']} retry * @returns {{ attempts: number, errors: string[], methods: string[] }} @@ -313,8 +331,7 @@ class Api { try { json = await r.json() } catch (e) { - // @ts-ignore - console.error('Unable to parse response json', e.message) + throw new BodyReadError(e) } } diff --git a/lib/index.spec.js b/lib/index.spec.js index 8abe621..b8a0bd4 100644 --- a/lib/index.spec.js +++ b/lib/index.spec.js @@ -1,6 +1,6 @@ import sinon from 'sinon' import { expect } from '@hapi/code' -import { Api } from './index.js' +import { Api, BodyReadError } from './index.js' const { stub } = sinon @@ -237,7 +237,7 @@ describe('util/api', () => { it('logs through the context logger before the default handler runs', async () => { const calls = [] - const logger = { error: (details, message) => calls.push(['log', details, message]) } + const logger = { error: (details, message) => calls.push([ 'log', details, message ]) } const api = createApi() @@ -246,7 +246,7 @@ describe('util/api', () => { await api .context({ fetch: clientStub, logger }) .endpoint('some/url') - .default(() => calls.push(['handler'])) + .default(() => calls.push([ 'handler' ])) .get() expect(calls.map(c => c[0])).to.equal([ 'log', 'handler' ]) @@ -294,6 +294,65 @@ describe('util/api', () => { }) }) + context('body read failure', () => { + let clientStub + let jsonFunction + + beforeEach(() => { + jsonFunction = stub().rejects(new TypeError('Failed to fetch')) + clientStub = stub().resolves(response({ statusText: 'OK', json: jsonFunction })) + }) + + it('routes the failure to the default handler', async () => { + let received + + await createApi() + .context({ fetch: clientStub }) + .endpoint('some/url') + .default(e => { received = e }) + .get() + + expect(received).to.be.an.error(BodyReadError, 'Failed to fetch') + expect(received.status).to.be.undefined() + expect(received.request).to.equal({ method: 'GET', url: 'https://example.com/api/v1/some/url' }) + }) + + it('propagates when the default handler rethrows', async () => { + let thrown + + try { + await createApi() + .context({ fetch: clientStub }) + .endpoint('some/url') + .default(e => { throw e }) + .get() + } catch (e) { + thrown = e + } + + expect(thrown).to.be.an.error(BodyReadError, 'Failed to fetch') + }) + + it('logs the failure through the context logger', async () => { + const calls = [] + const logger = { error: (details, message) => calls.push([ details, message ]) } + + await createApi() + .context({ fetch: clientStub, logger }) + .endpoint('some/url') + .default(() => {}) + .get() + + expect(calls.length).to.equal(1) + const [ details, message ] = calls[0] + expect(details.status).to.be.undefined() + expect(details.method).to.equal('GET') + expect(details.url).to.equal('https://example.com/api/v1/some/url') + expect(details.err).to.be.an.error() + expect(message).to.equal('GET https://example.com/api/v1/some/url failed (Failed to fetch)') + }) + }) + context('responds ok', () => { let api let clientStub @@ -1057,14 +1116,21 @@ describe('util/api', () => { }) }) - it('endpoint returns no data', async () => { - clientStub.resolves(response({ json: stub().rejects('Error') })) - expect( - await api - .context({ fetch: clientStub }) - .endpoint('some/url') - .put(() => true) - ).to.be.true() + it('endpoint with unreadable body resolves undefined when unhandled', async () => { + const consoleError = stub(console, 'error') + + try { + clientStub.resolves(response({ json: stub().rejects('Error') })) + + expect( + await api + .context({ fetch: clientStub }) + .endpoint('some/url') + .put(() => true) + ).to.be.undefined() + } finally { + consoleError.restore() + } }) it('sets an authorization header', async () => { diff --git a/lib/types.ts b/lib/types.ts index 47b24eb..bf95cf1 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -80,6 +80,8 @@ export type ErrorHandlers = { expectationFailed?: ErrorHandler; badData?: ErrorHandler; tooManyRequests?: ErrorHandler; + /** Called when a 2xx response body could not be downloaded or parsed */ + bodyRead?: ErrorHandler; /** Called when no request-level or status-specific handler matches */ default?: ErrorHandler; [key: string]: ErrorHandler | undefined; From ea6bf38ac6b2502e3cab1b152ea8d4b2d5ebebfd Mon Sep 17 00:00:00 2001 From: Sam Vargas Date: Thu, 15 Oct 2026 09:40:51 +0100 Subject: [PATCH 2/7] [BY-3684]: retry safe-method requests after a body read failure Browser body truncations (TypeError: Failed to fetch) carry no error code, so the configured errors list never matched them and a lost response body still surfaced as an error. A request whose body was cut after the headers were received is safe to repeat for idempotent methods, so retry it within the existing attempts budget regardless of the errors list. --- lib/index.js | 4 ++-- lib/index.spec.js | 55 +++++++++++++++++++++++++++++++++++++++++++++++ lib/types.ts | 4 ++-- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/lib/index.js b/lib/index.js index 9f06f07..d78d58f 100644 --- a/lib/index.js +++ b/lib/index.js @@ -353,11 +353,11 @@ class Api { const code = e.code || (e.cause && e.cause.code) const retriesCount = attempt - 1 const hasAttemptsLeft = retriesCount < retry.attempts - const isRetryableError = retry.errors.includes(code) + const isRetryableError = retry.errors.includes(code) || e instanceof BodyReadError const isRetryableMethod = retry.methods.includes(options.method) if (hasAttemptsLeft && isRetryableError && isRetryableMethod) { - console.warn(`Got ${code} when calling ${endpoint}. Retrying request (${attempt}/${retry.attempts})`) + console.warn(`Got ${code || e.message} when calling ${endpoint}. Retrying request (${attempt}/${retry.attempts})`) return this.#doQuery(++attempt, client, endpoint, options) } diff --git a/lib/index.spec.js b/lib/index.spec.js index b8a0bd4..25feeda 100644 --- a/lib/index.spec.js +++ b/lib/index.spec.js @@ -827,6 +827,61 @@ describe('util/api', () => { }) }) + context('body read failure retry', function () { + beforeEach(function () { + this.warn = stub(console, 'warn') + this.clientStub = stub() + this.clientStub.onFirstCall().resolves(response({ json: stub().rejects(new TypeError('Failed to fetch')) })) + this.clientStub.onSecondCall().resolves(response({ body: { foo: 'bar' } })) + }) + + afterEach(function () { + this.warn.restore() + }) + + it('retries a GET once and succeeds', async function () { + const res = await createApi() + .context({ fetch: this.clientStub }) + .endpoint('some/url') + .get(({ foo }) => foo) + + expect(this.clientStub.callCount).to.equal(2) + expect(res).to.equal('bar') + }) + + it('logs a warning for the retry', async function () { + await createApi() + .context({ fetch: this.clientStub }) + .endpoint('some/url') + .get(({ foo }) => foo) + + expect(this.warn.firstCall.args[0]).to.equal('Got Failed to fetch when calling https://example.com/api/v1/some/url. Retrying request (1/1)') + }) + + it('does not retry a POST', async function () { + let handled = false + + await createApi() + .context({ fetch: this.clientStub }) + .endpoint('some/url') + .default(() => { handled = true }) + .post() + + expect(this.clientStub.callCount).to.equal(1) + expect(handled).to.be.true() + }) + + it('does not retry when retry is disabled', async function () { + await createApi({ retry: false }) + .context({ fetch: this.clientStub }) + .endpoint('some/url') + .default(() => {}) + .get() + + expect(this.clientStub.callCount).to.equal(1) + }) + }) + context('error thrown in success handler is not caught', () => { const clientStub = stub() const defaultErrorHandler = stub() diff --git a/lib/types.ts b/lib/types.ts index bf95cf1..75ca8df 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -10,7 +10,7 @@ export type ApiOptions = { mock?: FetchClient; /** Fetch client for HTTP requests */ fetch?: FetchClient; - /** Retry settings. Omit to use the defaults (1 retry on ECONNRESET for GET, HEAD, OPTIONS), or pass false to never retry */ + /** Retry settings. Omit to use the defaults (1 retry on ECONNRESET for GET, HEAD, OPTIONS; failed body reads on safe methods also retry), or pass false to never retry */ retry?: RetryOptions | false; /** Whether to parse error responses as JSON */ parseErrors?: boolean; @@ -24,7 +24,7 @@ export type ApiOptions = { export type RetryOptions = { /** Number of retries after the first request. 0 never retries. Defaults to 1 */ attempts?: number; - /** Node network error codes to retry on, matched on the error or its cause. Defaults to ['ECONNRESET'] */ + /** Node network error codes to retry on, matched on the error or its cause. Defaults to ['ECONNRESET']. Failed body reads on safe methods always retry, regardless of this list */ errors?: string[]; /** HTTP methods that may be retried. Defaults to ['GET', 'HEAD', 'OPTIONS'] */ methods?: string[]; From 7751cdd91285bd2290aa2b0cf88d4430c2730b85 Mon Sep 17 00:00:00 2001 From: Sam Vargas Date: Thu, 15 Oct 2026 09:41:03 +0100 Subject: [PATCH 3/7] [BY-3684]: document body read errors and retry behaviour --- README.MD | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.MD b/README.MD index 6e7b5f6..7abf42b 100644 --- a/README.MD +++ b/README.MD @@ -194,6 +194,8 @@ Handlers have a signature with two items: Every error carries the request that failed as `e.request` (`{ method, url }`), and HTTP failures also carry `e.status` and the parsed response as `e.body`. Errors thrown by `fetch` itself, such as network failures or aborts, have `e.request` but no `e.status`. +A successful (2xx) response whose body failed to download or parse is surfaced as a `BodyReadError` carrying the underlying error as `e.cause`, and reaches the `bodyRead` handler, falling back to `default`. + ctx can be whatever you want really - it is whatever you pass in as `context(...)`. However, if the `context` object you pass in has a `fetch` function, this is used as the `fetch` for XHR requests. @@ -289,7 +291,7 @@ Api.configure({ retry: false }) // never retry Good to know: - Timeouts and HTTP error responses (4xx, 5xx) aren't retried. -- Retries only apply server-side, as browsers don't expose network error codes. +- Retries only apply server-side for network errors, as browsers don't expose error codes. A lost response body (headers received, body failed to download or parse) is retried on safe methods in both environments. - Each retry logs a warning, e.g. `Got ECONNRESET when calling https://…/api/v1/apps. Retrying request (1/1)`. ## Parsing error payloads From 25e2b6dea134339bb2d1cf43daa4497b6ca13e44 Mon Sep 17 00:00:00 2001 From: paulambanks Date: Thu, 15 Oct 2026 15:11:03 +0200 Subject: [PATCH 4/7] [BY-3684]: only wrap dropped-connection body errors and retry them on safe methods only --- README.MD | 4 +- lib/index.js | 18 ++--- lib/index.spec.js | 174 +++++++++++++++++++++++++--------------------- lib/types.ts | 6 +- 4 files changed, 110 insertions(+), 92 deletions(-) diff --git a/README.MD b/README.MD index 7abf42b..39ea1cc 100644 --- a/README.MD +++ b/README.MD @@ -194,7 +194,7 @@ Handlers have a signature with two items: Every error carries the request that failed as `e.request` (`{ method, url }`), and HTTP failures also carry `e.status` and the parsed response as `e.body`. Errors thrown by `fetch` itself, such as network failures or aborts, have `e.request` but no `e.status`. -A successful (2xx) response whose body failed to download or parse is surfaced as a `BodyReadError` carrying the underlying error as `e.cause`, and reaches the `bodyRead` handler, falling back to `default`. +If the connection drops while a successful response body is downloading, the request fails with a `BodyReadError` (original error on `e.cause`), which reaches the `default` handler. Other body errors, such as invalid JSON, reach the handlers unchanged. ctx can be whatever you want really - it is whatever you pass in as `context(...)`. @@ -291,7 +291,7 @@ Api.configure({ retry: false }) // never retry Good to know: - Timeouts and HTTP error responses (4xx, 5xx) aren't retried. -- Retries only apply server-side for network errors, as browsers don't expose error codes. A lost response body (headers received, body failed to download or parse) is retried on safe methods in both environments. +- Retries only apply server-side, as browsers don't expose network error codes. The exception is a `BodyReadError`, which is retried in both environments, but only for GET, HEAD and OPTIONS. - Each retry logs a warning, e.g. `Got ECONNRESET when calling https://…/api/v1/apps. Retrying request (1/1)`. ## Parsing error payloads diff --git a/lib/index.js b/lib/index.js index d78d58f..f7ab777 100644 --- a/lib/index.js +++ b/lib/index.js @@ -98,22 +98,22 @@ function getErrorByCode (code) { /** * Error thrown when a successful (2xx/3xx) response body could not be - * downloaded or parsed. The request reached the server and response headers + * downloaded. The request reached the server and response headers * were received, so the response is incomplete rather than absent. */ export class BodyReadError extends HttpError { /** * Create a new body read error - * @param {unknown} cause - The error thrown while reading the response body + * @param {Error} cause - The error thrown while reading the response body */ constructor (cause) { - super(cause instanceof Error ? cause.message : String(cause)) + super(cause.message) this.cause = cause - /** @type {string | undefined} Error code of the underlying cause, when present */ - this.code = cause && /** @type {{ code?: string }} */ (cause).code } } +const SAFE_METHODS = [ 'GET', 'HEAD', 'OPTIONS' ] + /** * @param {ApiOptions['retry']} retry * @returns {{ attempts: number, errors: string[], methods: string[] }} @@ -126,7 +126,7 @@ function retrySettings (retry) { const { attempts = 1, errors = [ 'ECONNRESET' ], - methods = [ 'GET', 'HEAD', 'OPTIONS' ] + methods = SAFE_METHODS } = retry || {} return { attempts, errors, methods } @@ -331,7 +331,7 @@ class Api { try { json = await r.json() } catch (e) { - throw new BodyReadError(e) + throw e instanceof TypeError ? new BodyReadError(e) : e } } @@ -353,7 +353,9 @@ class Api { const code = e.code || (e.cause && e.cause.code) const retriesCount = attempt - 1 const hasAttemptsLeft = retriesCount < retry.attempts - const isRetryableError = retry.errors.includes(code) || e instanceof BodyReadError + const isRetryableError = e instanceof BodyReadError + ? SAFE_METHODS.includes(options.method) + : retry.errors.includes(code) const isRetryableMethod = retry.methods.includes(options.method) if (hasAttemptsLeft && isRetryableError && isRetryableMethod) { diff --git a/lib/index.spec.js b/lib/index.spec.js index 25feeda..0daa126 100644 --- a/lib/index.spec.js +++ b/lib/index.spec.js @@ -294,62 +294,28 @@ describe('util/api', () => { }) }) - context('body read failure', () => { - let clientStub - let jsonFunction - - beforeEach(() => { - jsonFunction = stub().rejects(new TypeError('Failed to fetch')) - clientStub = stub().resolves(response({ statusText: 'OK', json: jsonFunction })) - }) + context('body read failure', function () { + beforeEach(async function () { + this.logger = { error: stub() } + const clientStub = stub().resolves(response({ json: stub().rejects(new TypeError('Failed to fetch')) })) - it('routes the failure to the default handler', async () => { - let received - - await createApi() - .context({ fetch: clientStub }) + await createApi({ retry: false }) + .context({ fetch: clientStub, logger: this.logger }) .endpoint('some/url') - .default(e => { received = e }) + .default(e => { this.received = e }) .get() - - expect(received).to.be.an.error(BodyReadError, 'Failed to fetch') - expect(received.status).to.be.undefined() - expect(received.request).to.equal({ method: 'GET', url: 'https://example.com/api/v1/some/url' }) }) - it('propagates when the default handler rethrows', async () => { - let thrown - - try { - await createApi() - .context({ fetch: clientStub }) - .endpoint('some/url') - .default(e => { throw e }) - .get() - } catch (e) { - thrown = e - } - - expect(thrown).to.be.an.error(BodyReadError, 'Failed to fetch') + it('routes a BodyReadError to the default handler', function () { + expect(this.received).to.be.an.error(BodyReadError, 'Failed to fetch') }) - it('logs the failure through the context logger', async () => { - const calls = [] - const logger = { error: (details, message) => calls.push([ details, message ]) } - - await createApi() - .context({ fetch: clientStub, logger }) - .endpoint('some/url') - .default(() => {}) - .get() + it('attaches the request', function () { + expect(this.received.request).to.equal({ method: 'GET', url: 'https://example.com/api/v1/some/url' }) + }) - expect(calls.length).to.equal(1) - const [ details, message ] = calls[0] - expect(details.status).to.be.undefined() - expect(details.method).to.equal('GET') - expect(details.url).to.equal('https://example.com/api/v1/some/url') - expect(details.err).to.be.an.error() - expect(message).to.equal('GET https://example.com/api/v1/some/url failed (Failed to fetch)') + it('logs the failure through the context logger', function () { + expect(this.logger.error.firstCall.args[1]).to.equal('GET https://example.com/api/v1/some/url failed (Failed to fetch)') }) }) @@ -831,7 +797,6 @@ describe('util/api', () => { beforeEach(function () { this.warn = stub(console, 'warn') this.clientStub = stub() - this.clientStub.onFirstCall().resolves(response({ json: stub().rejects(new TypeError('Failed to fetch')) })) this.clientStub.onSecondCall().resolves(response({ body: { foo: 'bar' } })) }) @@ -839,46 +804,99 @@ describe('util/api', () => { this.warn.restore() }) - it('retries a GET once and succeeds', async function () { - const res = await createApi() - .context({ fetch: this.clientStub }) - .endpoint('some/url') - .get(({ foo }) => foo) + function bodyFails (error) { + return response({ json: stub().rejects(error) }) + } + + context('GET with a lost body', function () { + beforeEach(async function () { + this.clientStub.onFirstCall().resolves(bodyFails(new TypeError('Failed to fetch'))) + + this.res = await createApi() + .context({ fetch: this.clientStub }) + .endpoint('some/url') + .get(({ foo }) => foo) + }) - expect(this.clientStub.callCount).to.equal(2) - expect(res).to.equal('bar') + it('retries once', function () { + expect(this.clientStub.callCount).to.equal(2) + }) + + it('returns the retried value', function () { + expect(this.res).to.equal('bar') + }) + + it('warns with the cause message', function () { + expect(this.warn.firstCall.args[0]).to.equal('Got Failed to fetch when calling https://example.com/api/v1/some/url. Retrying request (1/1)') + }) }) - it('logs a warning for the retry', async function () { - await createApi() - .context({ fetch: this.clientStub }) - .endpoint('some/url') - .get(({ foo }) => foo) + context('GET with a body that is not JSON', function () { + beforeEach(async function () { + this.clientStub.onFirstCall().resolves(bodyFails(new SyntaxError('Unexpected token <'))) + + await createApi() + .context({ fetch: this.clientStub }) + .endpoint('some/url') + .default(e => { this.received = e }) + .get() + }) - expect(this.warn.firstCall.args[0]).to.equal('Got Failed to fetch when calling https://example.com/api/v1/some/url. Retrying request (1/1)') + it('does not retry', function () { + expect(this.clientStub.callCount).to.equal(1) + }) + + it('passes the parse error to the handler as it is', function () { + expect(this.received).to.be.an.error(SyntaxError, 'Unexpected token <') + }) }) - it('does not retry a POST', async function () { - let handled = false + context('POST', function () { + beforeEach(async function () { + this.clientStub.onFirstCall().resolves(bodyFails(new TypeError('Failed to fetch'))) - await createApi() - .context({ fetch: this.clientStub }) - .endpoint('some/url') - .default(() => { handled = true }) - .post() + await createApi() + .context({ fetch: this.clientStub }) + .endpoint('some/url') + .default(() => {}) + .post() + }) - expect(this.clientStub.callCount).to.equal(1) - expect(handled).to.be.true() + it('does not retry', function () { + expect(this.clientStub.callCount).to.equal(1) + }) }) - it('does not retry when retry is disabled', async function () { - await createApi({ retry: false }) - .context({ fetch: this.clientStub }) - .endpoint('some/url') - .default(() => {}) - .get() + context('POST with methods configured', function () { + beforeEach(async function () { + this.clientStub.onFirstCall().resolves(bodyFails(new TypeError('Failed to fetch'))) + + await createApi({ retry: { methods: [ 'POST' ] } }) + .context({ fetch: this.clientStub }) + .endpoint('some/url') + .default(() => {}) + .post() + }) + + it('does not retry', function () { + expect(this.clientStub.callCount).to.equal(1) + }) + }) + + context('GET with retry disabled', function () { + beforeEach(async function () { + this.clientStub.onFirstCall().resolves(bodyFails(new TypeError('Failed to fetch'))) + + await createApi({ retry: false }) + .context({ fetch: this.clientStub }) + .endpoint('some/url') + .default(() => {}) + .get() + }) - expect(this.clientStub.callCount).to.equal(1) + it('does not retry', function () { + expect(this.clientStub.callCount).to.equal(1) + }) }) }) diff --git a/lib/types.ts b/lib/types.ts index 75ca8df..bd60c51 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -10,7 +10,7 @@ export type ApiOptions = { mock?: FetchClient; /** Fetch client for HTTP requests */ fetch?: FetchClient; - /** Retry settings. Omit to use the defaults (1 retry on ECONNRESET for GET, HEAD, OPTIONS; failed body reads on safe methods also retry), or pass false to never retry */ + /** Retry settings. Omit to use the defaults (1 retry on ECONNRESET for GET, HEAD, OPTIONS), or pass false to never retry */ retry?: RetryOptions | false; /** Whether to parse error responses as JSON */ parseErrors?: boolean; @@ -24,7 +24,7 @@ export type ApiOptions = { export type RetryOptions = { /** Number of retries after the first request. 0 never retries. Defaults to 1 */ attempts?: number; - /** Node network error codes to retry on, matched on the error or its cause. Defaults to ['ECONNRESET']. Failed body reads on safe methods always retry, regardless of this list */ + /** Node network error codes to retry on, matched on the error or its cause. Defaults to ['ECONNRESET']. A BodyReadError on GET, HEAD, OPTIONS always retries, regardless of this list */ errors?: string[]; /** HTTP methods that may be retried. Defaults to ['GET', 'HEAD', 'OPTIONS'] */ methods?: string[]; @@ -80,8 +80,6 @@ export type ErrorHandlers = { expectationFailed?: ErrorHandler; badData?: ErrorHandler; tooManyRequests?: ErrorHandler; - /** Called when a 2xx response body could not be downloaded or parsed */ - bodyRead?: ErrorHandler; /** Called when no request-level or status-specific handler matches */ default?: ErrorHandler; [key: string]: ErrorHandler | undefined; From 76050e5105bc916d454b65e7d260fd8f7e7f319f Mon Sep 17 00:00:00 2001 From: paulambanks Date: Thu, 15 Oct 2026 15:24:54 +0200 Subject: [PATCH 5/7] [BY-3684]: keep body read error internal and trim repeated tests --- README.MD | 4 ++-- lib/index.js | 8 ++----- lib/index.spec.js | 60 +++++++---------------------------------------- lib/types.ts | 2 +- 4 files changed, 14 insertions(+), 60 deletions(-) diff --git a/README.MD b/README.MD index 39ea1cc..6ba1032 100644 --- a/README.MD +++ b/README.MD @@ -194,7 +194,7 @@ Handlers have a signature with two items: Every error carries the request that failed as `e.request` (`{ method, url }`), and HTTP failures also carry `e.status` and the parsed response as `e.body`. Errors thrown by `fetch` itself, such as network failures or aborts, have `e.request` but no `e.status`. -If the connection drops while a successful response body is downloading, the request fails with a `BodyReadError` (original error on `e.cause`), which reaches the `default` handler. Other body errors, such as invalid JSON, reach the handlers unchanged. +If the connection drops while a successful response body is downloading, the request fails with an error that reaches the `default` handler, with the original error on `e.cause`. Other body errors, such as invalid JSON, reach the handlers unchanged. ctx can be whatever you want really - it is whatever you pass in as `context(...)`. @@ -291,7 +291,7 @@ Api.configure({ retry: false }) // never retry Good to know: - Timeouts and HTTP error responses (4xx, 5xx) aren't retried. -- Retries only apply server-side, as browsers don't expose network error codes. The exception is a `BodyReadError`, which is retried in both environments, but only for GET, HEAD and OPTIONS. +- Retries only apply server-side, as browsers don't expose network error codes. The exception is a response body lost mid-download, which is retried in both environments, but only for GET, HEAD and OPTIONS. - Each retry logs a warning, e.g. `Got ECONNRESET when calling https://…/api/v1/apps. Retrying request (1/1)`. ## Parsing error payloads diff --git a/lib/index.js b/lib/index.js index f7ab777..bb3fb2f 100644 --- a/lib/index.js +++ b/lib/index.js @@ -96,12 +96,8 @@ function getErrorByCode (code) { return errorMapping[code] || HttpError } -/** - * Error thrown when a successful (2xx/3xx) response body could not be - * downloaded. The request reached the server and response headers - * were received, so the response is incomplete rather than absent. - */ -export class BodyReadError extends HttpError { +/** Response body lost mid-download */ +class BodyReadError extends HttpError { /** * Create a new body read error * @param {Error} cause - The error thrown while reading the response body diff --git a/lib/index.spec.js b/lib/index.spec.js index 0daa126..e81eca4 100644 --- a/lib/index.spec.js +++ b/lib/index.spec.js @@ -1,6 +1,6 @@ import sinon from 'sinon' import { expect } from '@hapi/code' -import { Api, BodyReadError } from './index.js' +import { Api } from './index.js' const { stub } = sinon @@ -20,6 +20,10 @@ function fetchFailed (code) { return new TypeError('fetch failed', { cause }) } +function bodyFails (error) { + return response({ json: stub().rejects(error) }) +} + function errorWithCode (code) { const error = new Error(`read ${code}`) error.code = code @@ -296,26 +300,17 @@ describe('util/api', () => { context('body read failure', function () { beforeEach(async function () { - this.logger = { error: stub() } - const clientStub = stub().resolves(response({ json: stub().rejects(new TypeError('Failed to fetch')) })) + const clientStub = stub().resolves(bodyFails(new TypeError('Failed to fetch'))) await createApi({ retry: false }) - .context({ fetch: clientStub, logger: this.logger }) + .context({ fetch: clientStub }) .endpoint('some/url') .default(e => { this.received = e }) .get() }) - it('routes a BodyReadError to the default handler', function () { - expect(this.received).to.be.an.error(BodyReadError, 'Failed to fetch') - }) - - it('attaches the request', function () { - expect(this.received.request).to.equal({ method: 'GET', url: 'https://example.com/api/v1/some/url' }) - }) - - it('logs the failure through the context logger', function () { - expect(this.logger.error.firstCall.args[1]).to.equal('GET https://example.com/api/v1/some/url failed (Failed to fetch)') + it('passes the dropped connection to the default handler as the cause', function () { + expect(this.received.cause).to.be.an.error(TypeError, 'Failed to fetch') }) }) @@ -804,10 +799,6 @@ describe('util/api', () => { this.warn.restore() }) - function bodyFails (error) { - return response({ json: stub().rejects(error) }) - } - context('GET with a lost body', function () { beforeEach(async function () { this.clientStub.onFirstCall().resolves(bodyFails(new TypeError('Failed to fetch'))) @@ -882,22 +873,6 @@ describe('util/api', () => { expect(this.clientStub.callCount).to.equal(1) }) }) - - context('GET with retry disabled', function () { - beforeEach(async function () { - this.clientStub.onFirstCall().resolves(bodyFails(new TypeError('Failed to fetch'))) - - await createApi({ retry: false }) - .context({ fetch: this.clientStub }) - .endpoint('some/url') - .default(() => {}) - .get() - }) - - it('does not retry', function () { - expect(this.clientStub.callCount).to.equal(1) - }) - }) }) context('error thrown in success handler is not caught', () => { @@ -1189,23 +1164,6 @@ describe('util/api', () => { }) }) - it('endpoint with unreadable body resolves undefined when unhandled', async () => { - const consoleError = stub(console, 'error') - - try { - clientStub.resolves(response({ json: stub().rejects('Error') })) - - expect( - await api - .context({ fetch: clientStub }) - .endpoint('some/url') - .put(() => true) - ).to.be.undefined() - } finally { - consoleError.restore() - } - }) - it('sets an authorization header', async () => { clientStub.resolves(response({ body: { foo: 'bar' } })) diff --git a/lib/types.ts b/lib/types.ts index bd60c51..eec4459 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -24,7 +24,7 @@ export type ApiOptions = { export type RetryOptions = { /** Number of retries after the first request. 0 never retries. Defaults to 1 */ attempts?: number; - /** Node network error codes to retry on, matched on the error or its cause. Defaults to ['ECONNRESET']. A BodyReadError on GET, HEAD, OPTIONS always retries, regardless of this list */ + /** Node network error codes to retry on, matched on the error or its cause. Defaults to ['ECONNRESET']. A response body lost mid-download on GET, HEAD, OPTIONS always retries, regardless of this list */ errors?: string[]; /** HTTP methods that may be retried. Defaults to ['GET', 'HEAD', 'OPTIONS'] */ methods?: string[]; From a8407025fe779c9cc392549421bd71f47ca7a20c Mon Sep 17 00:00:00 2001 From: Paula Banks <32043316+paulambanks@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:25:31 +0100 Subject: [PATCH 6/7] Apply suggestion from @svargas-dev Co-authored-by: Sam Vargas <54634835+svargas-dev@users.noreply.github.com> --- lib/index.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/index.js b/lib/index.js index bb3fb2f..115bf4c 100644 --- a/lib/index.js +++ b/lib/index.js @@ -96,11 +96,9 @@ function getErrorByCode (code) { return errorMapping[code] || HttpError } -/** Response body lost mid-download */ class BodyReadError extends HttpError { /** - * Create a new body read error - * @param {Error} cause - The error thrown while reading the response body + * @param {Error} cause */ constructor (cause) { super(cause.message) From 38d0ec572cbb572644e0f11ce4038de65495df8b Mon Sep 17 00:00:00 2001 From: paulambanks Date: Tue, 20 Oct 2026 10:11:33 +0200 Subject: [PATCH 7/7] [BY-3684]: keep invalid json resolving undefined and tidy body read retry --- README.MD | 2 +- lib/index.js | 38 ++++++++++++++++++++++---------------- lib/index.spec.js | 29 +++++++++++++++++------------ 3 files changed, 40 insertions(+), 29 deletions(-) diff --git a/README.MD b/README.MD index 6ba1032..a1c5d07 100644 --- a/README.MD +++ b/README.MD @@ -194,7 +194,7 @@ Handlers have a signature with two items: Every error carries the request that failed as `e.request` (`{ method, url }`), and HTTP failures also carry `e.status` and the parsed response as `e.body`. Errors thrown by `fetch` itself, such as network failures or aborts, have `e.request` but no `e.status`. -If the connection drops while a successful response body is downloading, the request fails with an error that reaches the `default` handler, with the original error on `e.cause`. Other body errors, such as invalid JSON, reach the handlers unchanged. +If the connection drops while a successful response body is downloading, the request fails with an error that reaches the `default` handler, with the original error on `e.cause`. Invalid JSON still resolves `undefined`, as before. ctx can be whatever you want really - it is whatever you pass in as `context(...)`. diff --git a/lib/index.js b/lib/index.js index 115bf4c..4067906 100644 --- a/lib/index.js +++ b/lib/index.js @@ -64,6 +64,17 @@ class BadDataError extends HttpError {} /** 429 Too Many Requests error */ class TooManyRequestsError extends HttpError {} +/** Response body lost mid-download */ +class BodyReadError extends HttpError { + /** + * @param {Error} cause + */ + constructor (cause) { + super(cause.message) + this.cause = cause + } +} + /** * Map of HTTP status codes to error classes * @type {Record} @@ -96,16 +107,6 @@ function getErrorByCode (code) { return errorMapping[code] || HttpError } -class BodyReadError extends HttpError { - /** - * @param {Error} cause - */ - constructor (cause) { - super(cause.message) - this.cause = cause - } -} - const SAFE_METHODS = [ 'GET', 'HEAD', 'OPTIONS' ] /** @@ -325,7 +326,11 @@ class Api { try { json = await r.json() } catch (e) { - throw e instanceof TypeError ? new BodyReadError(e) : e + if (e instanceof TypeError) { + throw new BodyReadError(e) + } + // @ts-ignore + console.error('Unable to parse response json', e.message) } } @@ -347,13 +352,14 @@ class Api { const code = e.code || (e.cause && e.cause.code) const retriesCount = attempt - 1 const hasAttemptsLeft = retriesCount < retry.attempts - const isRetryableError = e instanceof BodyReadError - ? SAFE_METHODS.includes(options.method) - : retry.errors.includes(code) - const isRetryableMethod = retry.methods.includes(options.method) + const isBodyReadError = e instanceof BodyReadError + const isRetryableError = isBodyReadError || retry.errors.includes(code) + const isRetryableMethod = retry.methods.includes(options.method) && + (!isBodyReadError || SAFE_METHODS.includes(options.method)) if (hasAttemptsLeft && isRetryableError && isRetryableMethod) { - console.warn(`Got ${code || e.message} when calling ${endpoint}. Retrying request (${attempt}/${retry.attempts})`) + const reason = isBodyReadError ? `Lost response body (${e.message})` : `Got ${code}` + console.warn(`${reason} when calling ${endpoint}. Retrying request (${attempt}/${retry.attempts})`) return this.#doQuery(++attempt, client, endpoint, options) } diff --git a/lib/index.spec.js b/lib/index.spec.js index e81eca4..b8102de 100644 --- a/lib/index.spec.js +++ b/lib/index.spec.js @@ -20,7 +20,7 @@ function fetchFailed (code) { return new TypeError('fetch failed', { cause }) } -function bodyFails (error) { +function bodyFailed (error) { return response({ json: stub().rejects(error) }) } @@ -300,7 +300,7 @@ describe('util/api', () => { context('body read failure', function () { beforeEach(async function () { - const clientStub = stub().resolves(bodyFails(new TypeError('Failed to fetch'))) + const clientStub = stub().resolves(bodyFailed(new TypeError('Failed to fetch'))) await createApi({ retry: false }) .context({ fetch: clientStub }) @@ -801,7 +801,7 @@ describe('util/api', () => { context('GET with a lost body', function () { beforeEach(async function () { - this.clientStub.onFirstCall().resolves(bodyFails(new TypeError('Failed to fetch'))) + this.clientStub.onFirstCall().resolves(bodyFailed(new TypeError('Failed to fetch'))) this.res = await createApi() .context({ fetch: this.clientStub }) @@ -818,33 +818,28 @@ describe('util/api', () => { }) it('warns with the cause message', function () { - expect(this.warn.firstCall.args[0]).to.equal('Got Failed to fetch when calling https://example.com/api/v1/some/url. Retrying request (1/1)') + expect(this.warn.firstCall.args[0]).to.equal('Lost response body (Failed to fetch) when calling https://example.com/api/v1/some/url. Retrying request (1/1)') }) }) context('GET with a body that is not JSON', function () { beforeEach(async function () { - this.clientStub.onFirstCall().resolves(bodyFails(new SyntaxError('Unexpected token <'))) + this.clientStub.onFirstCall().resolves(bodyFailed(new SyntaxError('Unexpected token <'))) await createApi() .context({ fetch: this.clientStub }) .endpoint('some/url') - .default(e => { this.received = e }) .get() }) it('does not retry', function () { expect(this.clientStub.callCount).to.equal(1) }) - - it('passes the parse error to the handler as it is', function () { - expect(this.received).to.be.an.error(SyntaxError, 'Unexpected token <') - }) }) context('POST', function () { beforeEach(async function () { - this.clientStub.onFirstCall().resolves(bodyFails(new TypeError('Failed to fetch'))) + this.clientStub.onFirstCall().resolves(bodyFailed(new TypeError('Failed to fetch'))) await createApi() .context({ fetch: this.clientStub }) @@ -860,7 +855,7 @@ describe('util/api', () => { context('POST with methods configured', function () { beforeEach(async function () { - this.clientStub.onFirstCall().resolves(bodyFails(new TypeError('Failed to fetch'))) + this.clientStub.onFirstCall().resolves(bodyFailed(new TypeError('Failed to fetch'))) await createApi({ retry: { methods: [ 'POST' ] } }) .context({ fetch: this.clientStub }) @@ -1164,6 +1159,16 @@ describe('util/api', () => { }) }) + it('endpoint returns no data', async () => { + clientStub.resolves(response({ json: stub().rejects('Error') })) + expect( + await api + .context({ fetch: clientStub }) + .endpoint('some/url') + .put(() => true) + ).to.be.true() + }) + it('sets an authorization header', async () => { clientStub.resolves(response({ body: { foo: 'bar' } }))