fix(download): retry transient GitHub API/network failures during install - #68
Conversation
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
CHANGES-REQUIRED — head reviewed: dc17ef420b281d9ba9deaafcd5d28c73370e035a.
The diagnosis holds and the fix is the right shape. I independently confirmed the disproof of the keychain hypothesis: Cargo.toml:55 declares ureq = { version = "2", default-features = false, features = ["tls", "json", "gzip"] } and Cargo.lock contains webpki-roots (x2) and neither rustls-native-certs nor rustls-platform-verifier — the platform trust store is structurally unreachable from this binary. The 404 exclusion is genuinely load-bearing (download.rs:180-190 latest_release falls back to the releases list on is_release_not_found). The retry is provably bounded: with_retry runs attempts-1 loop iterations plus one final op(), and the backoff cannot grow past 2s at HTTP_ATTEMPTS = 3. All 12 required checks asserted BY NAME from branch protection are SUCCESS, 0 unresolved threads, and e2e run 31612407531 has head_sha == dc17ef42 with all 10 jobs green including install -> health -> uninstall (macos-14).
Two findings block. Neither is about the fix being wrong; one is a mangled string literal that makes the new test harness sleep instead of parse, and one is a residual failure mode observed on this PR that the retry predicate does not cover.
Also noted, non-gating and resolved by me: with_retry(0, ..) executes one attempt rather than zero (unreachable in production, both call sites pass HTTP_ATTEMPTS); is_release_not_found string-matches any message containing 404 (pre-existing, unchanged here).
SemVer 0.43.0 -> 0.43.1 is correct (behaviour-compatible reliability fix, no signature change). SPEC.md states the retry contract including the deliberate non-retries — §4.2 satisfied except for the 403 wording called out below.
…rting the install A single failed `api.github.com` request aborted the whole install and rolled back every completed step. On the macOS e2e leg that surfaced as a dropped TLS handshake (`invalid peer certificate ... UnsupportedCertVersion`) on the dig-app release lookup; sibling reds on the same workflow failed the same way on a 403. Every request was single-shot, so any blip was fatal. Route both request paths through a bounded retry (3 attempts, 500ms doubling backoff) that retries only transport failures, 429 and 5xx. A 404 stays a single request so `latest_release`'s no-published-release fallback is unchanged, and 401/403 still fail immediately. Co-Authored-By: Claude <noreply@anthropic.com>
SemVer: patch — a compatible reliability fix, no API or behaviour change beyond retrying failures that previously aborted the install. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…authenticated path only - Fix read_request_head CRLF header terminator (was bare LF, never matched 4-byte windows) - Fix server_dropping_first_requests response to use CRLF (was bare LF) - Add has_token bool to classify() to distinguish authenticated vs unauthenticated requests - Treat HTTP 403 as retryable only on fetch_bytes_retrying (no Authorization header) - Keep 403 non-retryable on get_text_with_token_retrying when a token is sent - Add regression test: fetch_bytes retries 403, get_text_with_token with token does not - Update SPEC.md §4.2 with corrected 403 rationale Tests: 37 download tests pass in 0.01s (was ~10s due to timeout fallback) Co-authored-by: MichaelTaylor3d <5665004+MichaelTaylor3d@users.noreply.github.com>
0c38087 to
2087cb5
Compare
…ial itself `cargo clippy -D warnings` failed on `token.map_or(false, ..)` (`clippy::unnecessary_map_or`), which would have blocked the merge gate. Rather than swap in `is_some_and`, collapse the two copies of the same predicate — the `has_token` flag and the `token.filter(..)` that sets the header — into ONE `credential` binding. The header and the retry policy now read from the same value, so they cannot drift into disagreeing about whether a request was authenticated, which is the exact distinction the 403 retry rule depends on. No behaviour change: `download::tests` 45/45 green, clippy and `cargo fmt --check` clean.
Revived + rebased — DO NOT MERGE, gates pendingBranch Done
State
RemainingThe gate round. Every check on this PR before today ran against a CONFLICTING head and proves nothing; the 12 required contexts must re-run on Off-path, logged not fixed (§2.6)
|
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
Correctness gate: PASS
Head read: 12ea3f48bb6d4aa0e12cc285a1e28680de5c801c (resolved from gh pr view 68 --json headRefOid; isDraft: true, mergeStateStatus: CLEAN). This is the correctness leg only — the credential-handling security leg runs separately.
1. Per-path 403 classification — correct
classify(url, err, authenticated) at src/download.rs:56 retries 429, >=500, and 403 only when !authenticated.
fetch_bytes_retrying(src/download.rs:285) passesfalseliterally, and that path provably sends noAuthorizationheader — so an anonymous rate-limit / abuse-detection 403 onreleases/downloadis retried. Right disposition.get_text_with_token_retrying(src/download.rs:251) passescredential.is_some()— derived, not restated. With a token, 403 is single-shot (rejected/exhausted credential). Without a token this path also treats 403 as retryable, which is correct rather than an oversight: an unauthenticatedapi.github.com403 is the anonymous rate limit, i.e. genuinely transient.401is retryable on neither path;404on neither. Both asserted.
2. The one-binding collapse — behaviour-preserving
The duplicated predicate is gone: credential = token.filter(|t| !t.is_empty()) (src/download.rs:245) is the single source for both the header and the authenticated flag, so they cannot drift. The None case is byte-identical to the pre-PR code (same filter, header omitted). The empty-string case is also coherent in the new shape: no header is sent and authenticated == false, so the 403 is attributed to anonymous rate-limiting rather than to a credential — which is exactly the disagreement the old map_or/filter pair could have produced.
3. Test load-bearingness — re-proved independently by mutation, in a throwaway worktree
I did not take the claim on trust. Three mutants, each on a fresh detached worktree at 12ea3f4 (removed afterwards; no shared checkout touched):
| mutant | result |
|---|---|
*code == 403 unconditionally (403 always retryable) |
fetch_bytes_retries_403_but_get_text_with_token_does_not FAILS |
| 403 arm deleted entirely | same test FAILS |
retry arm disabled (Err(f) if false && f.retryable) |
5 of 6 FAIL; only get_text_does_not_retry_a_404_... survives |
So the 403 test fails from both directions and therefore pins placement, not an outcome — the nearest wrong implementations (retry-everything, retry-nothing) are both rejected. The 404 test surviving is correct and expected: it is a guard against over-retrying, not a proof of the fix, and the lane characterised it that way.
Bound is also proven, not just presence: get_text_gives_up_once_the_retry_budget_is_spent asserts observed requests == HTTP_ATTEMPTS, so the loop cannot become unbounded — the other inequality of the retry guard. Assertions are on wire-observable request counts from a real local server, not on the loop's own branches, so there is no inexpressive-double surface here.
Unmutated: cargo test --lib download:: → 45 passed, 0 failed.
4. is_release_not_found substring match — not gating here
src/download.rs:174 is err.contains("404") || err.contains("Not Found"). It is pre-existing and unmodified, and this PR does not change the message format it reads (GET {url}: {err}), so the PR neither introduces nor widens the hazard. Judged safe in this context:
- Production callers only ever pass
api.github.com/repos/.../releases/latestURLs, none of which contain either token. - Production retry classification is on the typed
ureq::Error, never on this string, so a false positive here cannot cause a wrong retry decision. - The blast radius of a false positive is one extra
releaseslist request inlatest_release(src/download.rs:194), which then fails on its own merits. No wrong install, no silent success.
Correctly logged off-path under end-to-end-first. Noted as a non-gating observation below.
5. Version bump — consistent
0.45.0 → 0.45.1 in Cargo.toml, Cargo.lock, and gui/app/src-tauri/Cargo.lock. Patch is the right call: behaviour-preserving reliability fix, no API change.
dig-constants check (both directions, per §4.1)
- Does anything here belong in
dig-constants? No.HTTP_ATTEMPTS = 3andHTTP_RETRY_BACKOFF = 500msare this installer's private retry budget — no second repo must match them, nothing observes them on a wire, and they are not an asset id / port / address / puzzle hash / genesis value / trust anchor. - Should anything here be using
dig-constants? No hardcoded literal in this diff duplicates a valuedig-constantspublishes.USER_AGENTderives fromenv!("CARGO_PKG_VERSION"), and the GitHub host/paths come fromrelease::Repo, not from new literals.
Non-gating observations (no thread needed, recorded here)
src/download.rs:174—is_release_not_foundsubstring match, as analysed in §4. Pre-existing; safe in this codebase. Worth a comment on the tracking ticket rather than a fix in this PR.src/download.rs:1442— this assertion (is_release_not_found(&err)) would also pass if an ephemeral port number happened to contain404. It is not the load-bearing assertion in that test (seen == 1is), so this is a cosmetic weakness, not a false green.src/download.rs:99—with_retrycalled withattempts == 0would still perform one attempt (for _ in 1..0is empty, then the trailingop()). Unreachable in production (HTTP_ATTEMPTSis a const3; tests pass it explicitly), so a nit only.src/download.rs:262— aninto_string()failure is classified retryable, which also re-fetches onureq's ~10 MB body cap rather than failing fast. API bodies here are small; harmless.
None of these block merge. Merge remains gated on the parallel security leg returning and the PR being undrafted only after both verdicts are in.
loop-security: PASSAudited head: No gating security finding. This is the credential + network-trust leg; the correctness leg runs separately. 1. Credential leakage — CLEARThe token exists in exactly one place: Every failure path was traced, including the ones that only run on retry:
Redirect leak checked explicitly, since that is the classic way a Bearer header escapes: 2. The 403 classification — CLEAR, and structurally sound
3. Retry as an amplifier — CLEAR
4. Integrity of the artifact under retry — CLEARThe retry cannot produce a partial, spliced, or substituted artifact: 5. TLS — CLEAR, nothing relaxedThe diff changes no dependency (
Non-gating notes (all pre-existing; recorded, not a reason to hold this PR)
Required checks all green by name; zero unresolved threads. PR is correctly still a draft. |
DO NOT MERGE — gates pending. Rebased onto
main@05cc71a(v0.45.0) on 2026-08-20; every check recorded before that rebase ran against a CONFLICTING head and is meaningless. The gate round has not returned.Root cause
Every HTTP request the installer makes was single-shot, and any failure aborts the whole install and rolls back every completed step. On run
31553659321(dc598404) the dig-app release lookup lost its TLS handshake (invalid peer certificate: Other(OtherError(UnsupportedCertVersion))) and took the install down with it.The keychain hypothesis is disproved, not assumed away:
ureqis declareddefault-features = false, features = ["tls"], so its roots come from the compiled-in webpki-roots.Cargo.lockcontains norustls-native-certsand norustls-platform-verifier— the macOS system keychain is structurally not in the installer's trust set.The intermittency matches: the other recent reds on this workflow are the same shape with different blips —
31438236229a 403 onapi.github.com,31485103686a genuinely missingdigdasset (a real, separate gap, not this bug).Fix
A bounded retry (3 attempts, 500 ms doubling backoff) around both request paths (
get_text_with_token,fetch_bytes). Retries only what could answer differently: transport errors (DNS/connect/TLS handshake/dropped connection), 429, 5xx. Classification is on the typedureq::Error, never on a formatted string.Deliberately not retried: 404 (
latest_releasereads it as "no published release" and falls back to the releases list — retrying would triple every fallback), and 401 (a rejected credential).403 is path-dependent, per review: retried on the unauthenticated asset-download path (
fetch_bytes_retrying), where GitHub's 403 is an anonymous rate-limit/abuse-detection trip and transient by construction; not retried on the authenticated API path, where it means a rejected or exhausted token and no retry can change the answer. Onecredentialbinding feeds both theAuthorizationheader and the retry policy, so the two cannot drift apart.Regression tests
6 tests in
src/download.rsdriving the realureqrequest against a local server that drops a controlled number of requests (counting requests, not accepted sockets) and asserting the observed wire behaviour:HTTP_ATTEMPTS— a retry loop must not become an unbounded hangis_release_not_foundfetch_bytesretries a dropped connectionfetch_bytesretries a 403 (HTTP_ATTEMPTSrequests) whileget_text_with_tokenwith a token does not (exactly 1)Proven load-bearing by reverting only the fix, on committed state (2026-08-20):
Err(f) if false && f.retryable) → 5 of 6 fail; the 404 assertion stays green, because it is a guard rather than the fix.(*code == 403 && !authenticated)to*code == 403→ FAILS; drop the 403 arm entirely → FAILS. A one-directional fixture would have passed one of those.Blast radius
gitnexus was not used — no per-worktree index exists for this checkout and building one was not worth the 10-minute budget for a 1-file diff; radius established by ripgrep and stated here (§2.0 bound 2, fallback declared). Callers of the changed functions:
lib.rs:703/705(the install plan's release resolution) andupdate.rs:280(the update check). Both only gain retry on failures that previously aborted them.release.rsis URL construction only. No public signature changed;fetch_byteskeeps its signature.classifygained a third parameter and is private to the module.Verification (re-run after the rebase, at
12ea3f4)cargo test --lib: 865 pass, 1 fail —secure::tests::the_defect_reproduces_on_a_real_directory_and_the_repair_clears_it, which panics creating its fixture withOs { code: 5, PermissionDenied }and needs an elevated Windows shell.src/secure.rsis not in this diff; environmental, pre-existing.cargo test --lib download::: 45/45 in 0.37 s — confirming the\r\n\r\nharness fix; the pre-fix version spent ~1 s of socket timeout per request.cargo clippy --all-targets -- -D warnings: clean. It was failing at the pre-rebase head (clippy::unnecessary_map_orondownload.rs:247) — a required-check failure introduced by the review-fix commit and repaired in12ea3f4.cargo fmt --check: clean. Also failing at the pre-rebase head, same commit.Version
0.45.0 -> 0.45.1 — patch: a compatible reliability fix, no API change. (Was 0.43.1 before the rebase;
mainhas since released 0.44.1 and 0.45.0.)Refs DIG-Network/dig_ecosystem#2784