Skip to content

fix: release the response body on non-2xx HTTPProvider responses - #582

Merged
LautaroPetaccio merged 2 commits into
masterfrom
fix/httpprovider-cancel-error-response-body
Jun 24, 2026
Merged

LautaroPetaccio merged 2 commits into
masterfrom
fix/httpprovider-cancel-error-response-body

Conversation

@LautaroPetaccio

Copy link
Copy Markdown
Contributor

Problem

HTTPProvider.sendAsync discarded the fetch Response on a non-2xx status without reading or cancelling its body:

if (!$.ok) {
  callback(new Error('External error. response code: ' + $.status))   // body never consumed
} else {
  const json = await $.json()   // 2xx: body consumed
  ...
}

With native fetch (undici) and browser fetch, the underlying socket stays checked out until the response body is consumed or cancelled. So whenever an RPC endpoint returned a non-2xx (e.g. 429/5xx under load), the connection leaked. node-fetch's default non-keep-alive agent masked this, so it only surfaces once a consumer passes a native-fetch-backed FetchFunction.

Fix

Release the body before invoking the callback — cancel the stream when the implementation supports it (web streams), otherwise drain it via text() (e.g. node-fetch). The 2xx path is unchanged (it already drains via json()).

Tests

Added test/HTTPProvider.spec.ts covering: non-2xx with a cancellable body → cancel() called; non-2xx without cancel → drained via text(); 2xx → consumed via json(), not cancelled, result returned.

Verification

  • tsc typecheck: clean
  • make build: succeeds, API report unchanged (no public-API change)
  • new unit suite: 6/6 passing
  • lint: clean

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

Test this pull request

  • The package can be tested by running
    yarn upgrade "https://sdk-team-cdn.decentraland.org/"

@decentraland-bot decentraland-bot left a comment

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.

Code Review — PR #582

fix: release the response body on non-2xx HTTPProvider responses

Verdict: ✅ Approve

This is a solid, well-scoped fix for a real connection/socket leak. The feature-detection approach (cancel() for web streams, text() drain for node-fetch) is correct across all target runtimes, the .catch(() => undefined) guards are appropriate for best-effort cleanup, and the callback contract is preserved. CI passes, API report is unchanged (no public surface change), and the new test suite covers the main branches well.

No P0 or P1 issues found. Minor suggestions below.


P2 — Missing negative-path test coverage

test/HTTPProvider.spec.ts

The tests cover the happy cleanup paths but don't verify behavior when the cleanup itself fails:

  • cancel() rejecting (e.g. stream already errored)
  • text() rejecting (e.g. connection reset mid-drain)
  • $.body being null/undefined (some polyfills)

The production code handles all of these correctly (optional chaining + .catch()), but adding tests would lock in those guarantees against regressions.

P2 — Error body is discarded

src/providers/HTTPProvider.ts ~L79-83

The error response body (which may contain useful diagnostics — rate-limit details, server error messages) is cancelled/drained but never surfaced. An alternative approach would drain via text() and include the body in the error message:

const detail = await $.text().catch(() => '')
callback(new Error(`External error. response code: ${$.status}${detail ? ' — ' + detail : ''}`))

This would fix the leak and improve debuggability. However, this changes the error message format, so it's arguably a separate enhancement — not blocking.

P2 — Simpler single-path alternative

src/providers/HTTPProvider.ts ~L79-83

Calling $.text().catch(() => undefined) alone would work universally (drains the body on every fetch implementation). The cancel()-first path is a valid optimization (avoids buffering the error body into a string), but error responses are typically tiny, so the real-world gain is marginal. The current two-branch approach is defensible — just noting the simpler alternative exists.

P2 — Pre-existing: debug log serializes full response object

src/providers/HTTPProvider.ts L85 (not introduced by this PR)

JSON.stringify($) on the response could serialize reflected headers. Consider logging only safe fields (status, statusText, url) in a future cleanup.

Git conventions ✅

  • Title follows fix: <summary> format
  • Branch follows fix/<summary> pattern

Security ✅

No secrets, no injection vectors, no auth issues. The $.text() fallback theoretically allows draining an unbounded body from a malicious endpoint, but the host is set by the library consumer at construction time, making this low risk.

Consumer impact ✅

No public API change (confirmed by API report). Internal implementation only — safe for all downstream consumers (catalyst, builder, js-sdk-toolchain, worlds-content-server, etc.).


Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio via Slack

HTTPProvider.sendAsync discarded the fetch Response on a non-2xx status without
reading its body. With native fetch (undici) and browser fetch the underlying
socket stays checked out until the body is consumed or cancelled, so error
responses (e.g. an RPC endpoint returning 429/5xx) leaked connections under load.
node-fetch's default non-keep-alive agent masked this previously.

Drain the body via text() before invoking the callback (releasing the connection
across all fetch implementations) and surface the server's error detail in the
thrown error, bounded to 512 chars and best-effort, so non-2xx failures carry
useful diagnostics instead of a bare status code. The 2xx path is unchanged (it
already consumes the body via json()).

Adds unit tests covering the drain, error-detail surfacing, cleanup-failure, and
success paths.
@LautaroPetaccio
LautaroPetaccio force-pushed the fix/httpprovider-cancel-error-response-body branch from 5fd73fe to 345b22a Compare June 24, 2026 13:28
@LautaroPetaccio
LautaroPetaccio enabled auto-merge (squash) June 24, 2026 15:36
@LautaroPetaccio
LautaroPetaccio merged commit 5ab5390 into master Jun 24, 2026
3 checks passed
@LautaroPetaccio
LautaroPetaccio deleted the fix/httpprovider-cancel-error-response-body branch June 24, 2026 15:39
LautaroPetaccio added a commit to decentraland/archipelago-workers that referenced this pull request Jun 24, 2026
eth-connect 6.3.1 releases the response body on non-2xx HTTPProvider responses
(decentraland/eth-connect#582), which the ws-connector relies on for the on-chain
signature validation it runs on every WS authentication. Bumped in both core and
ws-connector; both resolve to 6.3.1 (the remaining root 6.2.4 is @dcl/crypto's
internal copy). The FetchFunction type is unchanged, so the existing native-fetch
cast stays.
LautaroPetaccio added a commit to decentraland/archipelago-workers that referenced this pull request Jun 24, 2026
)

* chore: migrate from @well-known-components to @dcl core-components

Replace the @well-known-components homonyms across the core, ws-connector
and stats workspaces with the published @dcl/* core-components, and bump
the existing @dcl dependencies to their latest versions.

Component swaps:
- http-server: @well-known-components/http-server -> @dcl/http-server@2 (core, stats)
- metrics:     @well-known-components/metrics     -> @dcl/metrics (core, ws-connector, stats)
- fetch:       @well-known-components/fetch-component -> @dcl/fetch-component (ws-connector, stats)
- test-helpers: @well-known-components/test-helpers -> @dcl/test-helpers (all workspaces)
- bump:        @dcl/uws-http-server -> ^1.0.1 (ws-connector)
- add:         @dcl/core-commons, @types/jest (the new @dcl/test-helpers declares
               @types/jest as a peer dependency instead of bundling it)

Adaptations:
- Move IHttpServerComponent / IFetchComponent to @dcl/core-commons in the
  component type maps so they match the native (global Request/Response) server
  and fetch returned by @dcl/http-server v2 and @dcl/fetch-component. The other
  interface types (IConfigComponent, ILoggerComponent, IBaseComponent,
  IMetricsComponent) stay on @well-known-components/interfaces.
- eth-connect's HTTPProvider and dcl-catalyst-client still type their fetch
  against node-fetch's IFetchComponent; the native fetch is runtime-compatible,
  so cast it at those call sites.
- Fix the createLocalFetchCompoment -> createLocalFetchComponent typo exposed by
  @dcl/test-helpers.

Stays on @well-known-components (no core equivalent): interfaces, logger,
env-config-provider, nats-component, pushable-channel.

* fix: pin semver to v7 so jest coverage reporter resolves semver/functions/gte

* chore: bump dcl-catalyst-client to v22 in stats to drop node-fetch

v22 replaced its cross-fetch/wkc-fetch deps (which pulled node-fetch@2)
with @dcl/fetch-component (native fetch) and accepts the native fetcher
directly, so the as-unknown-as IFetchComponent cast and the node-fetch
comment in stats/src/adapters/content.ts are no longer needed.

* chore: bump eth-connect to 6.3.1 for the HTTPProvider response-body fix

eth-connect 6.3.1 releases the response body on non-2xx HTTPProvider responses
(decentraland/eth-connect#582), which the ws-connector relies on for the on-chain
signature validation it runs on every WS authentication. Bumped in both core and
ws-connector; both resolve to 6.3.1 (the remaining root 6.2.4 is @dcl/crypto's
internal copy). The FetchFunction type is unchanged, so the existing native-fetch
cast stays.

* chore: bump eth-connect to 6.4.0 and drop the native-fetch cast

eth-connect 6.4.0 (decentraland/eth-connect#583) makes FetchFunction accept the
native fetch directly, so the `fetch.fetch as unknown as FetchFunction` cast in
the ws-connector HTTPProvider wiring is no longer needed. Bumped eth-connect in
core and ws-connector, removed the cast and the now-unused FetchFunction import.
Type-only change; no runtime behavior change.
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