-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix(http): retry transient HTTP failures with backoff and warn on rescue #9414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
c17df5f
fix(http): retry transient HTTP failures with backoff and warn on rescue
jdx 043fc68
fix(http): vfox honors MISE_HTTP_RETRIES, fix clippy lint
jdx 5be4c19
fix(http): don't silently cap retries at the backoff schedule length
jdx bd8f74d
fix(http): warn immediately on transient failure, not just on rescue
jdx faa8562
fix(vfox): don't log "succeeded" when last retry returns a 5xx
jdx 8e3590f
refactor(http): drop redundant rescue/exhaustion warnings
jdx 2cd5b8f
fix(http): restore offline-mode check in download_file_with_headers
jdx 30125b6
fix(http): use equal jitter so backoff can't drop near zero
jdx a76e825
chore(deps): drop tokio-retry, no longer used
jdx ccb2d06
chore(deps): regenerate Cargo.lock after dropping tokio-retry
jdx File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,122 @@ | ||
| use reqwest::{Client, ClientBuilder}; | ||
| use reqwest::{Client, ClientBuilder, StatusCode}; | ||
| use std::sync::LazyLock; | ||
| use std::time::Duration; | ||
|
|
||
| pub static CLIENT: LazyLock<Client> = LazyLock::new(|| { | ||
| ClientBuilder::new() | ||
| .user_agent(format!("vfox.rs/{}", env!("CARGO_PKG_VERSION"))) | ||
| .build() | ||
| .expect("Failed to create reqwest client") | ||
| }); | ||
|
|
||
| /// Default retry attempts when MISE_HTTP_RETRIES is unset. Mirrors the | ||
| /// `http_retries` setting default in the main mise crate. | ||
| const DEFAULT_HTTP_RETRIES: usize = 3; | ||
|
|
||
| /// Backoff schedule (ms) shared with the main mise crate. Hand-rolled rather | ||
| /// than using ExponentialBackoff::from_millis (which is geometric in the base | ||
| /// value) so the human-readable cadence is obvious. Jitter is applied per delay. | ||
| const BACKOFF_SCHEDULE_MS: &[u64] = &[200, 1_000, 4_000, 15_000]; | ||
|
|
||
| /// Read MISE_HTTP_RETRIES so vfox honors the same opt-out as the rest of mise. | ||
| /// vfox is a separate crate without access to mise's Settings layer, so the env | ||
| /// var is the only shared signal. | ||
| fn http_retries() -> usize { | ||
| std::env::var("MISE_HTTP_RETRIES") | ||
| .ok() | ||
| .and_then(|s| s.parse::<usize>().ok()) | ||
| .unwrap_or(DEFAULT_HTTP_RETRIES) | ||
| } | ||
|
|
||
| /// Total attempts = retries + initial attempt. | ||
| pub(crate) fn http_retry_attempts() -> usize { | ||
| http_retries().saturating_add(1) | ||
| } | ||
|
|
||
| pub(crate) fn should_retry_status(status: StatusCode) -> bool { | ||
| let code = status.as_u16(); | ||
| code == 408 || code == 429 || (500..600).contains(&code) | ||
| } | ||
|
|
||
| pub(crate) fn is_transient(err: &reqwest::Error) -> bool { | ||
| if err.is_timeout() || err.is_connect() || err.is_body() { | ||
| return true; | ||
| } | ||
| if let Some(status) = err.status() { | ||
| return should_retry_status(status); | ||
| } | ||
| false | ||
| } | ||
|
|
||
| /// Backoff for the `n`-th retry (0-indexed). Falls back to the longest delay | ||
| /// in the schedule for retries beyond it. A small uniform jitter in [50%, 100%] | ||
| /// of the base avoids thundering herd while keeping delays at least half the | ||
| /// nominal value. | ||
| pub(crate) fn retry_delay(attempt: usize) -> Duration { | ||
| let base_ms = BACKOFF_SCHEDULE_MS | ||
| .get(attempt) | ||
| .copied() | ||
| .unwrap_or_else(|| *BACKOFF_SCHEDULE_MS.last().unwrap()); | ||
| // Cheap deterministic-ish jitter from the system clock — vfox is a small | ||
| // crate and pulling in `rand` just for this isn't worth it. | ||
| let jitter_pct = 50 | ||
| + (std::time::SystemTime::now() | ||
| .duration_since(std::time::UNIX_EPOCH) | ||
| .map(|d| d.subsec_nanos() % 50) | ||
| .unwrap_or(0)) as u64; | ||
| Duration::from_millis(base_ms * jitter_pct / 100) | ||
| } | ||
|
|
||
| /// Retry an async operation that issues a request AND extracts the body. | ||
| /// Use for download/text/bytes flows where mid-stream failures (is_body()) need | ||
| /// to restart the whole request. Warns immediately on each transient failure | ||
| /// (so users see flakiness without waiting through the backoff) and again on | ||
| /// eventual success or final exhaustion. | ||
| pub(crate) async fn retry_async<F, Fut, T>( | ||
| url: &str, | ||
| mut f: F, | ||
| ) -> std::result::Result<T, reqwest::Error> | ||
| where | ||
| F: FnMut() -> Fut, | ||
| Fut: std::future::Future<Output = std::result::Result<T, reqwest::Error>>, | ||
| { | ||
| let attempts = http_retry_attempts().max(1); | ||
| let mut had_transient_failure = false; | ||
| let mut last_err: Option<reqwest::Error> = None; | ||
| for attempt in 0..attempts { | ||
| match f().await { | ||
| Ok(value) => { | ||
| if had_transient_failure { | ||
| log::warn!("HTTP {} succeeded on attempt {}", url, attempt + 1); | ||
| } | ||
| return Ok(value); | ||
| } | ||
| Err(err) => { | ||
| if !is_transient(&err) { | ||
| return Err(err); | ||
| } | ||
| if attempt + 1 >= attempts { | ||
| log::warn!( | ||
| "HTTP {} failed after {} attempts: {}", | ||
| url, | ||
| attempt + 1, | ||
| err | ||
| ); | ||
| return Err(err); | ||
| } | ||
| let delay = retry_delay(attempt); | ||
| log::warn!( | ||
| "HTTP {} attempt {} failed (transient): {}; retrying in {:?}", | ||
| url, | ||
| attempt + 1, | ||
| err, | ||
| delay | ||
| ); | ||
| had_transient_failure = true; | ||
| last_err = Some(err); | ||
| tokio::time::sleep(delay).await; | ||
| } | ||
| } | ||
| } | ||
| Err(last_err.expect("retry loop should always return")) | ||
| } |
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
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
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
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
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.