From c521863dbcc8044ffc4dca87104b5e1c8f7dab5c Mon Sep 17 00:00:00 2001 From: Max Kalashnikoff Date: Mon, 8 Sep 2025 20:13:01 +0200 Subject: [PATCH 1/2] feat: adding a basic retrying --- Cargo.toml | 1 + src/registry/client.rs | 57 ++++++++++++++++++++++++++---------------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1c38077..7aa7b4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ regex = "1.6" reqwest = { version = "0.11", features = ["json"] } thiserror = "1.0" url = "2.5.0" +tracing = "0.1" [dev-dependencies] tokio = { version = "1.29.1", features = ["full"] } diff --git a/src/registry/client.rs b/src/registry/client.rs index a668ac2..ae3e871 100644 --- a/src/registry/client.rs +++ b/src/registry/client.rs @@ -13,6 +13,7 @@ use { }, serde::{de::DeserializeOwned, Deserialize, Serialize}, std::{fmt::Debug, time::Duration}, + tracing::error, }; #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone)] @@ -72,17 +73,24 @@ pub struct HttpClientConfig { /// The timeout is applied for both the connect phase of a `Client`, and for /// fully receiving response body. /// - /// Default is no timeout. + /// Default is 5 seconds. pub timeout: Option, + + /// Number of times to retry a failed request. + /// + /// Default is 2 retries. + pub max_retries: usize, } impl Default for HttpClientConfig { fn default() -> Self { - // These defaults are taken from `reqwest` default config. Self { + // These defaults are taken from `reqwest` default config. pool_idle_timeout: Some(Duration::from_secs(90)), pool_max_idle: usize::MAX, - timeout: None, + timeout: Some(Duration::from_secs(5)), + // 2 retries if the first request fails. + max_retries: 2, } } } @@ -94,6 +102,7 @@ pub struct RegistryHttpClient { http_client: reqwest::Client, st: String, sv: String, + max_retries: usize, } impl RegistryHttpClient { @@ -161,9 +170,30 @@ impl RegistryHttpClient { http_client: http_client.build().map_err(RegistryError::BuildClient)?, st: st.to_string(), sv: sv.to_string(), + max_retries: config.max_retries, }) } + async fn get_with_retry(&self, url: Url) -> RegistryResult { + let mut last_err: Option = None; + for _attempt in 0..=self.max_retries { + match self.http_client.get(url.clone()).send().await { + Ok(resp) => return Ok(resp), + Err(err) => { + error!("Error fetching URL {url}: {err}. Retrying."); + last_err = Some(err); + } + } + } + error!( + "Failed to fetch URL {url} after {0} attempts: {last_err:?}", + self.max_retries + ); + Err(RegistryError::Transport( + last_err.expect("max_retries >= 0 guarantees at least one attempt"), + )) + } + async fn project_data_impl( &self, project_id: &str, @@ -176,12 +206,7 @@ impl RegistryHttpClient { let url = build_explorer_url(&self.base_explorer_url, project_id, quota) .map_err(RegistryError::UrlBuild)?; - let resp = self - .http_client - .get(url) - .send() - .await - .map_err(RegistryError::Transport)?; + let resp = self.get_with_retry(url).await?; parse_http_response(resp).await } @@ -198,12 +223,7 @@ impl RegistryHttpClient { build_internal_api_url(&self.base_internal_api_url, project_id, &self.st, &self.sv) .map_err(RegistryError::UrlBuild)?; - let resp = self - .http_client - .get(url) - .send() - .await - .map_err(RegistryError::Transport)?; + let resp = self.get_with_retry(url).await?; parse_http_response(resp).await } @@ -238,12 +258,7 @@ impl RegistryHttpClient { let url = build_features_url(&self.base_internal_api_url, project_id, &self.st, &self.sv) .map_err(RegistryError::UrlBuild)?; - let resp = self - .http_client - .get(url) - .send() - .await - .map_err(RegistryError::Transport)?; + let resp = self.get_with_retry(url).await?; parse_http_response(resp).await } From 5a8b12913670c306aeb86bdf1dbced73673c436c Mon Sep 17 00:00:00 2001 From: Max Kalashnikoff Date: Tue, 9 Sep 2025 07:40:47 +0200 Subject: [PATCH 2/2] feat: implementing backoff --- Cargo.toml | 1 + src/registry/client.rs | 25 ++++++++++++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7aa7b4f..85688ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ reqwest = { version = "0.11", features = ["json"] } thiserror = "1.0" url = "2.5.0" tracing = "0.1" +tokio = { version = "1.29.1", features = ["time"] } [dev-dependencies] tokio = { version = "1.29.1", features = ["full"] } diff --git a/src/registry/client.rs b/src/registry/client.rs index ae3e871..6f7f046 100644 --- a/src/registry/client.rs +++ b/src/registry/client.rs @@ -13,6 +13,7 @@ use { }, serde::{de::DeserializeOwned, Deserialize, Serialize}, std::{fmt::Debug, time::Duration}, + tokio::time::sleep, tracing::error, }; @@ -80,6 +81,11 @@ pub struct HttpClientConfig { /// /// Default is 2 retries. pub max_retries: usize, + + /// Initial backoff delay before the first retry. + /// + /// Default is 100 milliseconds. + pub initial_backoff: Duration, } impl Default for HttpClientConfig { @@ -91,6 +97,8 @@ impl Default for HttpClientConfig { timeout: Some(Duration::from_secs(5)), // 2 retries if the first request fails. max_retries: 2, + // Default retry backoff is 100 milliseconds. + initial_backoff: Duration::from_millis(100), } } } @@ -103,6 +111,7 @@ pub struct RegistryHttpClient { st: String, sv: String, max_retries: usize, + initial_backoff: Duration, } impl RegistryHttpClient { @@ -171,23 +180,33 @@ impl RegistryHttpClient { st: st.to_string(), sv: sv.to_string(), max_retries: config.max_retries, + initial_backoff: config.initial_backoff, }) } async fn get_with_retry(&self, url: Url) -> RegistryResult { let mut last_err: Option = None; - for _attempt in 0..=self.max_retries { + for attempt in 0..=self.max_retries { match self.http_client.get(url.clone()).send().await { Ok(resp) => return Ok(resp), Err(err) => { - error!("Error fetching URL {url}: {err}. Retrying."); + if attempt < self.max_retries { + let capped_attempt = attempt.min(20); + let multiplier = 1u64 << capped_attempt; + let base_ms = self.initial_backoff.as_millis() as u64; + let delay_ms = base_ms.saturating_mul(multiplier); + error!("Error fetching URL {url}: {err}. Retrying in {delay_ms}ms."); + sleep(Duration::from_millis(delay_ms)).await; + } else { + error!("Error fetching URL {url}: {err}. No more retries left."); + } last_err = Some(err); } } } error!( "Failed to fetch URL {url} after {0} attempts: {last_err:?}", - self.max_retries + self.max_retries + 1 ); Err(RegistryError::Transport( last_err.expect("max_retries >= 0 guarantees at least one attempt"),