Download Policy
Rule
Every network download rsconstruct performs MUST go through src/download.rs.
Call sites MUST NOT invoke curl directly, and MUST NOT call ureq without
wrapping the call in download::with_retry. New code containing a naked
Command::new("curl") WILL be rejected in code review.
The three entry points are:
| Helper | Use for |
|---|---|
download::curl_argv(url, dest) | Downloading a URL to a file. Returns argv; the caller executes it. |
download::apply_retry_args(&mut cmd) | A curl Command needing flags this module does not own (--head, -X PUT, -w %{http_code}). |
download::with_retry(closure) | HTTP performed in-process via ureq. |
Why
1. curl does not retry, and neither does ureq
Neither tool retries by default. curl needs explicit --retry flags;
ureq has connection pooling, which is easy to mistake for retry, but a
transport failure surfaces straight to the caller. A naked call at either
layer is a call with no resilience at all — and nothing about the call site
makes that visible to a reader.
2. One place to change policy
Before this module there were four independent curl -fsSL invocations
across tools.rs and remote_cache.rs, plus two bare ureq calls. Changing
retry behavior, adding a timeout, or setting a header meant finding all six
and editing them consistently. Anything less than all six produces a
codebase where robustness depends on which code path you happen to hit.
3. Preview must match execution
tool install renders its steps for preview (describe_binary) and
separately executes them (run_binary). Those were two hand-written argv
literals that had to be kept in sync by hand. Both now call curl_argv, so
the preview cannot drift from what actually runs.
What the retry is and is not for
This policy exists for connection-level failures: a connection reset,
refused, or dropped during handshake. The motivating incident was a
tool install run that died on:
curl: (35) Recv failure: Connection reset by peer
FAILED [binary] rumdl, taplo: curl -fsSL -o /tmp/taplo.dl https://... exited with code 35
The connection died about half a second in. Re-running the identical job minutes later succeeded in 43 seconds. That is the failure shape a retry fixes: the request never got started, so trying again costs almost nothing and usually works.
It is explicitly not for a slow-but-progressing transfer. The 2026-08-19 incidents involved a mirror serving at roughly 60 KB/s — slow, but making progress. Against that, a retry accomplishes nothing (each attempt is equally slow) and a total-transfer timeout is actively harmful: one such timeout killed a healthy release build. This is why:
--connect-timeoutis set,--max-timeis not. Bounding the handshake catches a dead endpoint. Bounding the whole transfer kills a working download of a large asset over a slow link. A unit test insrc/download.rsasserts--max-timestays absent.- The attempt count is 3, not 10. Three attempts absorb a transient reset. A larger number just means a genuinely broken endpoint takes longer to report a failure that was never going to resolve.
The remedy for a degraded runner remains what it was: cancel and re-run on fresh hardware, in the consuming repo’s CI. This module does not change that and cannot.
Flags, and why each is there
--retry 3 --retry-delay 2 --retry-connrefused --retry-all-errors --connect-timeout 30
--retry-connrefused and --retry-all-errors carry the weight. Plain
--retry covers HTTP 5xx responses and a narrow set of transport errors —
it does not cover a connection reset during handshake, which is exit
code 35 and precisely the case this policy addresses. Without those two
flags the retry count would have made no difference to the incident above.
Rate limits are fixed by authenticating, not by retrying
A rate limit is not a connection failure: GitHub answers 429 (or 403 on the
API) to anonymous requests per source IP, and hosted runners share egress
IPs, so another job may have spent the budget before ours starts. Retrying
within seconds hits the same exhausted budget. CI run 37439740429 failed
exactly so, fetching checkpatch.pl from raw.githubusercontent.com
after all three retries.
The remedy is the account’s own, much larger, limit. When GITHUB_TOKEN
or GH_TOKEN is set (the CI workflow forwards GITHUB_TOKEN to the
install step), curl_argv authenticates downloads from
raw.githubusercontent.com:
--variable %GITHUB_TOKEN --expand-header 'Authorization: Bearer {{GITHUB_TOKEN}}'
curl reads the token from the environment itself (curl 8.3+), so argv —
printed by tool install -i and visible in ps — names the variable and
never holds the secret. Other hosts get no header. The releases API lookup
(tools::resolve_latest_deb_asset) is authenticated the same way, from the
same variable choice (download::github_token_var).
Adding a new download
- Downloading a URL to a file? Use
curl_argv. Do not write acurlargv by hand. - Need curl flags the module does not own? Build the
Command, then callapply_retry_argson it before adding your flags. - Fetching in-process with
ureq? Wrap the call inwith_retry, and keep the whole request-and-read sequence inside the closure — a retry that re-reads a consumed body is not a retry. - Tempted to add a timeout knob? Read “What the retry is and is not for” above first. A total-transfer timeout has already broken a release build in this project once.
Any new download call site should carry a comment pointing at this document,
as the ureq sites in src/tools.rs and src/webcache.rs do.
Audit
rg -n 'Command::new\("curl"\)' src/
rg -n 'ureq::' src/
Every Command::new("curl") match must be followed by an
apply_retry_args call, and every ureq:: match must sit inside a
with_retry closure. As of this writing that is three curl sites (all in
src/remote_cache.rs) and two ureq sites (src/tools.rs,
src/webcache.rs). The binary installer in src/tools.rs builds its argv
through curl_argv and so does not spawn curl by name at all.