From 70c018bf4d586741f83f4f66a0020bec14f88b44 Mon Sep 17 00:00:00 2001 From: Clemens Stolle Date: Mon, 26 Oct 2026 13:34:27 +0000 Subject: [PATCH] reject the request promise when reading a response body fails. Fixes issue #375 When the connection breaks off after the response headers, reading the body fails. For JSON responses that error was swallowed and the request resolved with `''`; for text and binary responses it escaped the un-awaited responseHandler, leaving the request promise unsettled and causing an unhandled rejection. Read JSON bodies as text before parsing them, so that only parse errors are ignored, and chain responseHandler in relax() so that any failure rejects the request. Co-Authored-By: Claude Opus 5.5 --- lib/nano.js | 7 +++- test/nano.responsebody.test.js | 71 ++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 test/nano.responsebody.test.js diff --git a/lib/nano.js b/lib/nano.js index 25c59be..26a2364 100644 --- a/lib/nano.js +++ b/lib/nano.js @@ -149,8 +149,10 @@ module.exports = exports = function dbScope (cfg) { if (req.method === 'head') { retval = Object.fromEntries(response.headers) } else if (contentType && contentType.startsWith('application/json') && !opts.dontParse) { + // a failed read rejects, but a body that isn't JSON resolves as a blank string + const text = await response.text() try { - retval = await response.json() + retval = JSON.parse(text) } catch { // do nothing } @@ -428,7 +430,8 @@ module.exports = exports = function dbScope (cfg) { } else { return new Promise((resolve, reject) => { fetch(fetchOptions.url, fetchOptions).then((response) => { - responseHandler(response, fetchOptions, opts, resolve, reject) + // return the promise so that errors reading the body are caught below + return responseHandler(response, fetchOptions, opts, resolve, reject) }).catch((e) => { responseHandler(e, fetchOptions, opts, resolve, reject) }) diff --git a/test/nano.responsebody.test.js b/test/nano.responsebody.test.js new file mode 100644 index 0000000..15ea19a --- /dev/null +++ b/test/nano.responsebody.test.js @@ -0,0 +1,71 @@ +// Licensed under the Apache License, Version 2.0 (the 'License'); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. + +const test = require('node:test') +const assert = require('node:assert/strict') +const http = require('node:http') +const Nano = require('..') + +// mocks +// undici's MockAgent always delivers a complete response body, so these tests +// use a real local HTTP server that can break off a response mid-body +const responses = { + '/db/json': { type: 'application/json', body: '{"_id":"json","a":', complete: false }, + '/db/text': { type: 'text/plain', body: 'some text', complete: false }, + '/db/binary': { type: 'application/octet-stream', body: 'some bytes', complete: false }, + '/db/notjson': { type: 'application/json', body: 'not json', complete: true } +} +const server = http.createServer((req, res) => { + const r = responses[req.url] + // when incomplete, promise more bytes than are sent, then drop the connection + const length = r.complete ? r.body.length : 100 + res.writeHead(200, { 'content-type': r.type, 'content-length': length }) + res.write(r.body) + if (r.complete) { + res.end() + } else { + setTimeout(() => { res.socket.destroy() }, 10) + } +}) +let db + +test.before(async () => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const nano = Nano(`http://127.0.0.1:${server.address().port}`) + db = nano.db.use('db') +}) + +test.after(() => { + server.closeAllConnections() + server.close() +}) + +test('should reject when a JSON response body breaks off - db.get', { timeout: 5000 }, async () => { + // test GET /db/json + await assert.rejects(db.get('json'), { message: /error happened in your connection/ }) +}) + +test('should reject when a text response body breaks off - db.get', { timeout: 5000 }, async () => { + // test GET /db/text + await assert.rejects(db.get('text'), { message: /error happened in your connection/ }) +}) + +test('should reject when a binary response body breaks off - db.get', { timeout: 5000 }, async () => { + // test GET /db/binary + await assert.rejects(db.get('binary'), { message: /error happened in your connection/ }) +}) + +test('should resolve a complete JSON response body that cannot be parsed as a blank string - db.get', { timeout: 5000 }, async () => { + // test GET /db/notjson + const p = await db.get('notjson') + assert.equal(p, '') +})