Skip to content

[BY-3684]: surface and retry lost response bodies - #53

Open
svargas-dev wants to merge 5 commits into
masterfrom
feature/by-3684
Open

svargas-dev wants to merge 5 commits into
masterfrom
feature/by-3684

Conversation

@svargas-dev

@svargas-dev svargas-dev commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Why

When a 2xx response body was cut off mid-download, send() logged it and resolved undefined, so callers crashed destructuring the result (Sentry MARKETING-APP-K, 3 events). The browser error ("Failed to fetch") has no error code, so the retry config never matched it.

What

  • A body cut off mid-download (TypeError from r.json()) now fails with an error that reaches the default handler, with the original error on e.cause.
  • Other body errors (invalid JSON, abort, timeout) reach the handlers unchanged and aren't retried.
  • A lost body is retried once within the existing attempts budget, only for GET, HEAD and OPTIONS, even if methods lists others.
  • The retry warning falls back to the error message when there's no code.

Testing

  • 81 passing; lint and tsc clean.
  • Checked the error each runtime throws for a cut-off body, against a local server: Node, Chromium and WebKit all throw TypeError; invalid JSON, abort and timeout don't. Firefox follows the Fetch spec (4.7, step 18.2.2).

Heads up

  • Behaviour change: a 2xx whose body isn't valid JSON (or is empty without content-length) used to resolve undefined. It now reaches the error handlers as a SyntaxError. Inside SvelteKit universal loads an empty body still resolves undefined, as SvelteKit parses it itself.
  • Trade-off: when a POST, PUT, PATCH or DELETE gets a 2xx but its body is lost, the write has already happened. It used to run the success callback with undefined; now it reaches the error handler and isn't retried, so the user may see an error for a write that worked. That's rare (MARKETING-APP-K has 3 events) and safer than repeating the write.
  • MARKETING-APP-F (the other crash on BY-3684) is a different bug: a 401 skipping the login redirect in minified builds. That's BY-3731.

@pkg-pr-new

pkg-pr-new Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@beyonk/http@53

commit: 76050e5

@svargas-dev
svargas-dev marked this pull request as ready for review October 15, 2026 08:51
Comment thread lib/index.js Outdated
} catch (e) {
// @ts-ignore
console.error('Unable to parse response json', e.message)
throw new BodyReadError(e)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wraps every r.json() failure, including a SyntaxError from a complete but non-JSON 200 (say a proxy's HTML page during a redeploy) and an AbortError if the caller cancelled. Retrying either of those just repeats the same result: I ran it and a GET returning HTML makes two calls, and an abort during the body read also retries, which goes against the README's "timeouts aren't retried". Could we let SyntaxError and AbortError/TimeoutError through as they are, and only wrap actual transport failures?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Only a TypeError from r.json() becomes a BodyReadError now, which is what Node, Chromium and WebKit all throw when the body is cut off. Invalid JSON, aborts and timeouts reach the handlers as they are and aren't retried.

Comment thread lib/index.js Outdated
} catch (e) {
// @ts-ignore
console.error('Unable to parse response json', e.message)
throw new BodyReadError(e)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behaviour change to be aware of: a 200 with an empty body and no content-length used to resolve fine, and now it reaches the error handler. With content-length: 0 it's still fine. I couldn't find an API route that sends that, but it's worth checking, because dashboard's surfaceError default would turn it into a 500.

Comment thread lib/index.js
Comment thread package.json
{
"name": "@beyonk/http",
"version": "13.0.0",
"version": "13.0.1",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Master is already on 13.0.1, which was published on 8 October without this change, so this needs to be 13.0.2.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've dropped the version change from the PR instead. Releases get their version in a separate commit on master, so this can go out as 13.0.2 that way.

Comment thread lib/index.spec.js Outdated
})
})

context('body read failure', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this block use this. for its setup like the retry block below, instead of the floating lets? And could each it have one expect? "routes the failure to the default handler" and "logs the failure through the context logger" check several things at once.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Both blocks use this. for setup now, with one expect per test.

svargas-dev and others added 4 commits October 15, 2026 15:11
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.
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.

@svargas-dev svargas-dev left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. Since I opened the original PR, I can't approve. This could do with another set of eyes anyway, though, to be on the safe side

Comment thread lib/index.js
Comment on lines +99 to +104
/** 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
*/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given your Ant reviewer, I would have thought it would have picked up on the comments!

Suggested change
/** 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
*/
class BodyReadError extends HttpError {
/**
* @param {Error} cause
*/

Comment thread lib/index.js
} catch (e) {
// @ts-ignore
console.error('Unable to parse response json', e.message)
throw e instanceof TypeError ? new BodyReadError(e) : e

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants