Retry binary downloads on transient HTTP failures - #996
Open
X-Guardian wants to merge 1 commit into
Open
X-Guardian wants to merge 1 commit into
X-Guardian wants to merge 1 commit into
Conversation
|
OX Security reviewed this pull request — nothing to fix.
Branch |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #694.
Summary
place_binarymade exactly one download attempt, so a transient 5xx, a 429, or a dropped connection forced a source compile even when the request may have succeeded moments later. This PR adds bounded retries with exponential backoff around the download request.It also sets
statusCodeon HTTP download errors.print_fallback_erroralready branches on that property to produce itsTried to download(<status>)diagnostic, but the thrown error never carried it, so every HTTP failure took the generic "not installable" branch. That fix is included here rather than split out because the feature depends on it: once a download can fail after several attempts, the fallback message needs to distinguish an exhausted 5xx from an instant 404.What changed
fetch_with_retry()wraps the download request, retrying transient failures with exponential backoff and full jitter. It returns theResponseunchanged for any non-retryable outcome, so the 403-authenticated path and the extraction logic below it are untouched.resolve_retry_opts()resolves the new settings from an explicit option, then npm config, then the default, mirroring howproxyandcafileare sourced.statusCode.configDefsand copied ontooptsalongside the existingca/cafilecopy.What is retried
ECONNRESET,ECONNREFUSED,ETIMEDOUT,EPIPE,ENETUNREACH,EHOSTUNREACHEAI_AGAINsocket hang up, node-fetchrequest-timeout/body-timeoutENOTFOUNDbinary.host; treated as permanentAbortErrorThe 404 and 403 exclusions are deliberate and load-bearing for install performance, not just correctness. A module with no pre-built binary for the running ABI hits the 404 path on every install; adding retry delay there would slow down every such install everywhere. Both are covered by tests.
Retrying the request, never the stream
The retry loop wraps the request and its status check only. Once the response body is piped into
tar.extract, the download is committed.A mid-stream failure has already written partial files into
opts.module_path. Retrying without cleanup would extract over a half-written tree, and any file the first attempt created but the second did not reach would be left truncated. A truncated.nodewould then satisfy theexistsAsync(binary_module)check on a subsequent install and fail later atrequiretime, which is a considerably worse failure than the one being fixed.Handling that safely means extracting to a temp directory and renaming on success. That is a larger change and is deliberately out of scope here; the constraint is commented in the code so it is not "fixed" later without that context.
Options
--retries=<n>node_pre_gyp_retries2(3 attempts total)--retry_delay=<ms>node_pre_gyp_retry_delay1000--timeout=<ms>node_pre_gyp_timeout30000--retries=0disables retrying entirely, for CI that would rather fail fast and compile.Defaults are conservative and match npm's own
fetch-retriesdefault of 2. Backoff uses full jitter rather than thedelay/2 + randomvariant, because npm installs many packages in parallel and full jitter is what actually spreads out a fleet of machines all pulling from one struggling host.The per-attempt timeout bounds the worst case against a host that is slow to fail, such as the reported 504 that took around 11 seconds, and it makes a fully hung connection retryable rather than an indefinite stall.
It applies to the response headers only, not the body transfer, so a slow but progressing download of a large binary is never killed part-way. That follows from the response body being piped straight into tar rather than buffered: node-fetch only arms its body timeout inside
consumeBody(), which this path never calls. Worth knowing if anyone later changes the download to buffer the response, since that would start enforcing the timeout across the whole transfer.body-timeoutis treated as retryable so the behaviour would stay correct if that happened.User-visible output
Before, for the transient 504 in the linked issue:
After, in the common case the retry succeeds and the install completes with a single warning:
If every attempt fails, the fallback message is now the status-aware one that
print_fallback_errorwas always intended to produce:Retry warnings are logged at
warnso they are visible at the default loglevel: a silent multi-second stall duringnpm installis confusing, and a silent successful retry would hide a degrading binary host.No new dependencies
Nothing retry-related was added.
make-fetch-happenwould replacenode-fetchand pull in roughly ten transitive packages, which is a transport rewrite rather than an addition.p-retryandpromise-retrywould each add a dependency (plusretry) to save about 25 lines, and neither handles the part that actually matters here: deciding retryability from a node-fetchResponseversus aFetchError, and leaving 403 alone so the authenticated path still fires immediately.Given node-pre-gyp is an install-time dependency of a great many native addons, the bar for adding to that tree is high. The logic is small enough to keep local, and the repo already hand-rolls its proxy agent and S3 setup.
Tests
New
test/retry.test.js, following the existing nock and tape patterns intest/private-binary.test.js. 87 assertions covering:statusCodeENOTFOUNDnot retried, verified by leaving a follow-up interceptor unconsumedcodeand as a baresocket hang updelayConnectionretries: 0disabling retriesNaNThe integration tests pass their retry settings through the same
gyp.optspath the feature uses, so a broken config copy surfaces as a slow or failing test rather than a silent pass.versioning.evaluate()builds a freshoptsobject and discards anything it does not read, which is why the new settings are copied explicitly alongsideca/cafile. The existingopts.proxyhas that latent bug today and is never populated.