Repository navigation
fix: release the response body on non-2xx HTTPProvider responses - #582
Conversation
Test this pull request
|
decentraland-bot
left a comment
There was a problem hiding this comment.
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)$.bodybeingnull/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.
5fd73fe to
345b22a
Compare
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: 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.
Problem
HTTPProvider.sendAsyncdiscarded the fetchResponseon a non-2xx status without reading or cancelling its body: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/5xxunder 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-backedFetchFunction.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 viajson()).Tests
Added
test/HTTPProvider.spec.tscovering: non-2xx with a cancellable body →cancel()called; non-2xx withoutcancel→ drained viatext(); 2xx → consumed viajson(), not cancelled, result returned.Verification
tsctypecheck: cleanmake build: succeeds, API report unchanged (no public-API change)🤖 Generated with Claude Code