From 765f13cc4129ef2592de922f65b8f2dc67c8cb12 Mon Sep 17 00:00:00 2001 From: Denis Cornehl Date: Mon, 9 Feb 2026 20:15:10 +0100 Subject: [PATCH] more detailed error handling in registry-api, show crates.io errors to users --- Cargo.lock | 6 + .../bin/docs_rs_web/src/handlers/releases.rs | 116 +++--- .../templates/releases/search_results.html | 3 + crates/lib/docs_rs_registry_api/Cargo.toml | 8 + crates/lib/docs_rs_registry_api/src/api.rs | 341 +++++++++++++----- crates/lib/docs_rs_registry_api/src/error.rs | 88 +++++ crates/lib/docs_rs_registry_api/src/lib.rs | 2 + crates/lib/docs_rs_registry_api/src/models.rs | 40 ++ crates/lib/docs_rs_utils/src/lib.rs | 5 +- 9 files changed, 442 insertions(+), 167 deletions(-) create mode 100644 crates/lib/docs_rs_registry_api/src/error.rs diff --git a/Cargo.lock b/Cargo.lock index d5a9a859f..3159caf34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2265,9 +2265,15 @@ dependencies = [ "docs_rs_env_vars", "docs_rs_types", "docs_rs_utils", + "mime", + "mockito", "reqwest 0.13.2", "serde", + "serde_json", "sqlx", + "test-case", + "thiserror 2.0.18", + "tokio", "tracing", "url", ] diff --git a/crates/bin/docs_rs_web/src/handlers/releases.rs b/crates/bin/docs_rs_web/src/handlers/releases.rs index a55f2c06c..1e090ef23 100644 --- a/crates/bin/docs_rs_web/src/handlers/releases.rs +++ b/crates/bin/docs_rs_web/src/handlers/releases.rs @@ -11,7 +11,7 @@ use crate::{ metrics::WebMetrics, page::templates::{RenderBrands, RenderRegular, RenderSolid, filters}, }; -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, anyhow}; use askama::Template; use axum::{ extract::{Extension, Query}, @@ -24,6 +24,7 @@ use docs_rs_registry_api::{self as registry_api, RegistryApi}; use docs_rs_types::{KrateName, ReqVersion, Version}; use docs_rs_uri::encode_url_path; use futures_util::stream::TryStreamExt; +use http::StatusCode; use serde::Deserialize; use sqlx::Row; use std::{ @@ -155,7 +156,7 @@ async fn get_search_results( registry: &RegistryApi, query_params: &str, query: &str, -) -> Result { +) -> Result { let registry_api::Search { crates, meta } = registry.search(query_params).await?; let names = Arc::new( @@ -221,7 +222,8 @@ async fn get_search_results( ) }) .try_collect() - .await?; + .await + .map_err(|err| anyhow!(err))?; // start with the original names from crates.io to keep the original ranking, // extend with the release/build information from docs.rs @@ -430,6 +432,7 @@ pub(crate) async fn owner_handler(Path(owner): Path) -> AxumResult, pub(crate) releases: Vec, pub(crate) search_query: Option, pub(crate) search_sort_by: Option, @@ -444,6 +447,7 @@ impl Default for Search { fn default() -> Self { Self { title: String::default(), + message: None, releases: Vec::default(), search_query: None, previous_page_link: None, @@ -604,7 +608,7 @@ pub(crate) async fn search_handler( } } - get_search_results(&mut conn, ®istry, query_params, "").await? + get_search_results(&mut conn, ®istry, query_params, "").await } else if !query.is_empty() { let query_params: String = form_urlencoded::Serializer::new(String::new()) .append_pair("q", &query) @@ -612,31 +616,46 @@ pub(crate) async fn search_handler( .append_pair("per_page", &RELEASES_IN_RELEASES.to_string()) .finish(); - get_search_results(&mut conn, ®istry, &query_params, &query).await? + get_search_results(&mut conn, ®istry, &query_params, &query).await } else { return Err(AxumNope::NoResults); }; - let title = if search_result.results.is_empty() { - format!("No results found for '{query}'") - } else { - format!("Search results for '{query}'") - }; + match search_result { + Ok(search_result) => { + let title = if search_result.results.is_empty() { + format!("No results found for '{query}'") + } else { + format!("Search results for '{query}'") + }; - Ok(Search { - title, - releases: search_result.results, - search_query: Some(query), - search_sort_by: Some(sort_by), - next_page_link: search_result - .next_page - .map(|params| format!("/releases/search?paginate={}", b64.encode(params))), - previous_page_link: search_result - .prev_page - .map(|params| format!("/releases/search?paginate={}", b64.encode(params))), - ..Default::default() + Ok(Search { + title, + releases: search_result.results, + search_query: Some(query), + search_sort_by: Some(sort_by), + next_page_link: search_result + .next_page + .map(|params| format!("/releases/search?paginate={}", b64.encode(params))), + previous_page_link: search_result + .prev_page + .map(|params| format!("/releases/search?paginate={}", b64.encode(params))), + ..Default::default() + } + .into_response()) + } + Err(err) => { + warn!(%query, ?err, "error during crate search"); + + Ok(Search { + title: format!("Error searching for '{query}'"), + message: Some(err.to_string()), + status: err.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + ..Default::default() + } + .into_response()) + } } - .into_response()) } #[derive(Template)] @@ -1199,55 +1218,6 @@ mod tests { }) } - #[tokio::test(flavor = "multi_thread")] - async fn crates_io_errors_as_status_code_200() -> Result<()> { - let mut crates_io = mockito::Server::new_async().await; - - let env = TestEnvironment::builder() - .registry_api_config( - docs_rs_registry_api::Config::builder() - .registry_api_host(crates_io.url().parse().unwrap()) - .build(), - ) - .build() - .await?; - - let _m = crates_io - .mock("GET", "/api/v1/crates") - .match_query(Matcher::AllOf(vec![ - Matcher::UrlEncoded("q".into(), "doesnt_matter_here".into()), - Matcher::UrlEncoded("per_page".into(), "30".into()), - ])) - .with_status(200) - .with_header("content-type", "application/json") - .with_body( - json!({ - "errors": [ - { "detail": "error name 1" }, - { "detail": "error name 2" }, - ] - }) - .to_string(), - ) - .create_async() - .await; - - let response = env - .web_app() - .await - .get("/releases/search?query=doesnt_matter_here") - .await?; - assert_eq!(response.status(), 500); - - assert!( - response - .text() - .await? - .contains("error name 1\nerror name 2") - ); - Ok(()) - } - #[test_case(StatusCode::NOT_FOUND)] #[test_case(StatusCode::INTERNAL_SERVER_ERROR)] #[test_case(StatusCode::BAD_GATEWAY)] @@ -1282,7 +1252,7 @@ mod tests { .await .get("/releases/search?query=doesnt_matter_here") .await?; - assert_eq!(response.status(), 500); + assert_eq!(response.status(), status); assert!(response.text().await?.contains(&format!("{status}"))); Ok(()) diff --git a/crates/bin/docs_rs_web/templates/releases/search_results.html b/crates/bin/docs_rs_web/templates/releases/search_results.html index 07752b5f2..ed27dd607 100644 --- a/crates/bin/docs_rs_web/templates/releases/search_results.html +++ b/crates/bin/docs_rs_web/templates/releases/search_results.html @@ -3,6 +3,9 @@ {%- block header -%} {% call release_macros::search_header(title=title) %}{% endcall %} + {% if let Some(message) = message %} +

{{ message|linebreaksbr }}

+ {% endif %} {%- endblock header -%} {%- block topbar -%} diff --git a/crates/lib/docs_rs_registry_api/Cargo.toml b/crates/lib/docs_rs_registry_api/Cargo.toml index dd80266be..fc5f8faa2 100644 --- a/crates/lib/docs_rs_registry_api/Cargo.toml +++ b/crates/lib/docs_rs_registry_api/Cargo.toml @@ -15,6 +15,14 @@ docs_rs_types = { path = "../docs_rs_types" } docs_rs_utils = { path = "../docs_rs_utils" } reqwest = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } sqlx = { workspace = true } +thiserror = { workspace = true } tracing = { workspace = true } url = { workspace = true } + +[dev-dependencies] +mime = { workspace = true } +mockito = { workspace = true } +test-case = { workspace = true } +tokio = { workspace = true } diff --git a/crates/lib/docs_rs_registry_api/src/api.rs b/crates/lib/docs_rs_registry_api/src/api.rs index 1a32a8737..492a914d7 100644 --- a/crates/lib/docs_rs_registry_api/src/api.rs +++ b/crates/lib/docs_rs_registry_api/src/api.rs @@ -1,13 +1,14 @@ use crate::{ Config, - models::{CrateData, CrateOwner, OwnerKind, ReleaseData, Search, SearchCrate, SearchMeta}, + error::{Error, Result}, + models::{ApiErrors, CrateData, CrateOwner, OwnerKind, ReleaseData, Search, SearchResponse}, }; -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{Context, anyhow}; use chrono::{DateTime, Utc}; use docs_rs_types::{KrateName, Version}; use docs_rs_utils::{APP_USER_AGENT, retry_async}; use reqwest::header::{ACCEPT, HeaderValue, USER_AGENT}; -use serde::Deserialize; +use serde::{Deserialize, de::DeserializeOwned}; use tracing::instrument; use url::Url; @@ -45,44 +46,76 @@ impl RegistryApi { }) } - #[instrument(skip(self))] - pub async fn get_crate_data(&self, name: &KrateName) -> Result { - let owners = self - .get_owners(name) - .await - .context(format!("Failed to get owners for {name}"))?; + /// Make a request to crates.io, parse the response as JSON. + /// + /// We retry on + /// * server-error responses (5xx) + /// * other connection errors from reqwest + /// + /// We don't retry on all other status codes, as they are likely to be successful, or + /// client errors (4xx), or other unexpected responses that won't succeed on retry. + /// For debugging we include the response body in errors, either plain text or parsed + /// when the response has the crates.io error format. + /// + /// We treat 5xx errors just as text, not knowing where they were raised. + /// For 4xx errors we try to parse the the JSON error description. + async fn request(&self, url: &Url) -> Result + where + T: DeserializeOwned, + { + let response = retry_async( + || async { + // Make the request. + // This would error on connection errors etc. + let response = self.client.get(url.clone()).send().await?; + + if response.status().is_server_error() { + // this just to let reqwest generate us its "standard" error + let err = response.error_for_status_ref().unwrap_err(); + let text = response.text().await.unwrap_or_default(); + // we only want to retry on 5xx errors. + // for client errors we assume that trying again is not worth it. + Err(Error::HttpError(err, text)) + } else { + Ok::<_, Error>(response) + } + }, + self.max_retries, + ) + .await?; + + let status = response.status(); + + if status.is_success() { + Ok(response.json().await?) + } else { + let text = response.text().await.unwrap_or_default(); - Ok(CrateData { owners }) + if let Ok(api_errors) = serde_json::from_str::(&text) { + Err(Error::CrateIoApiError(status, api_errors)) + } else { + Err(Error::CrateIoError(status, text)) + } + } } #[instrument(skip(self))] - pub async fn get_release_data( - &self, - name: &KrateName, - version: &Version, - ) -> Result { - let (release_time, yanked, downloads) = self - .get_release_time_yanked_downloads(name, version) - .await - .context(format!("Failed to get crate data for {name}-{version}"))?; - - Ok(ReleaseData { - release_time, - yanked, - downloads, + pub async fn get_crate_data(&self, name: &KrateName) -> Result { + Ok(CrateData { + owners: self.get_owners(name).await?, }) } - /// Get release_time, yanked and downloads from the registry's API - async fn get_release_time_yanked_downloads( + #[instrument(skip(self))] + pub async fn get_release_data( &self, name: &KrateName, version: &Version, - ) -> Result<(DateTime, bool, i32)> { + ) -> Result { let url = { let mut url = self.api_base.clone(); url.path_segments_mut() - .map_err(|()| anyhow!("Invalid API url"))? + .map_err(|_| Error::InvalidApiUrl)? .extend(&["api", "v1", "crates", name.as_str(), "versions"]); url }; @@ -103,20 +136,7 @@ impl RegistryApi { downloads: i32, } - let response: Response = retry_async( - || async { - Ok(self - .client - .get(url.clone()) - .send() - .await? - .error_for_status()?) - }, - self.max_retries, - ) - .await? - .json() - .await?; + let response: Response = self.request(&url).await?; let version = response .versions @@ -124,7 +144,11 @@ impl RegistryApi { .find(|data| data.num == *version) .with_context(|| anyhow!("Could not find version in response"))?; - Ok((version.created_at, version.yanked, version.downloads)) + Ok(ReleaseData { + release_time: version.created_at, + yanked: version.yanked, + downloads: version.downloads, + }) } /// Fetch owners from the registry's API @@ -132,7 +156,7 @@ impl RegistryApi { let url = { let mut url = self.api_base.clone(); url.path_segments_mut() - .map_err(|()| anyhow!("Invalid API url"))? + .map_err(|()| Error::InvalidApiUrl)? .extend(&["api", "v1", "crates", name.as_str(), "owners"]); url }; @@ -152,20 +176,7 @@ impl RegistryApi { kind: Option, } - let response: Response = retry_async( - || async { - Ok(self - .client - .get(url.clone()) - .send() - .await? - .error_for_status()?) - }, - self.max_retries, - ) - .await? - .json() - .await?; + let response: Response = self.request(&url).await?; let result = response .users @@ -187,57 +198,203 @@ impl RegistryApi { Ok(result) } - /// Fetch crates from the registry's API + /// Fetch crates from the registry's API. pub async fn search(&self, query_params: &str) -> Result { - #[derive(Deserialize, Debug)] - struct SearchError { - detail: String, - } - - #[derive(Deserialize, Debug)] - struct SearchResponse { - crates: Option>, - meta: Option, - errors: Option>, - } - let url = { let mut url = self.api_base.clone(); url.path_segments_mut() - .map_err(|()| anyhow!("Invalid API url"))? + .map_err(|()| Error::InvalidApiUrl)? .extend(&["api", "v1", "crates"]); url.set_query(Some(query_params)); url }; - let response: SearchResponse = retry_async( - || async { - Ok(self - .client - .get(url.clone()) - .send() - .await? - .error_for_status()?) + let response: SearchResponse = self.request(&url).await?; + + Ok(Search { + crates: response.crates.ok_or(Error::MissingReleases)?, + meta: response.meta.ok_or(Error::MissingMetadata)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{ApiError, SearchCrate, SearchMeta}; + use reqwest::{StatusCode, header::CONTENT_TYPE}; + use serde::Serialize; + use test_case::test_case; + + async fn test_search(status: StatusCode, body: impl Serialize) -> Result { + let mut crates_io_api = mockito::Server::new_async().await; + + let _m = crates_io_api + .mock("GET", "/api/v1/crates?q=foo") + .with_status(status.as_u16().into()) + .with_header(CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .with_body(serde_json::to_vec(&body).unwrap()) + .create_async() + .await; + + let api = RegistryApi::new(crates_io_api.url().parse().unwrap(), 0)?; + api.search("q=foo").await + } + + #[test] + fn test_error_without_status() { + for err in [ + Error::InvalidApiUrl, + Error::MissingReleases, + Error::MissingMetadata, + Error::Other(anyhow!("some error")), + ] { + assert!(err.status().is_none()); + } + } + + #[test] + fn test_error_with_included_status() { + let status = StatusCode::INTERNAL_SERVER_ERROR; + + assert!(Error::CrateIoApiError(status, ApiErrors::default()).status() == Some(status)); + + assert!(Error::CrateIoError(status, "".into()).status() == Some(status)); + } + + #[tokio::test] + async fn test_error_reqwest_error_status() -> Result<()> { + let status = StatusCode::INTERNAL_SERVER_ERROR; + + let mut srv = mockito::Server::new_async().await; + let _m = srv + .mock("GET", "/") + .with_status(status.as_u16().into()) + .create_async() + .await; + + let err = reqwest::get(&srv.url()) + .await? + .error_for_status() + .unwrap_err(); + + assert_eq!(err.status(), Some(status)); + + Ok(()) + } + + #[tokio::test] + async fn test_search_ok() -> Result<()> { + let crates = vec![ + SearchCrate { name: "foo".into() }, + SearchCrate { name: "bar".into() }, + ]; + let meta = SearchMeta { + next_page: Some("next".into()), + prev_page: Some("prev".into()), + }; + + let result = test_search( + StatusCode::OK, + SearchResponse { + crates: Some(crates.clone()), + meta: Some(meta.clone()), }, - self.max_retries, ) - .await? - .json() .await?; - if let Some(errors) = response.errors { - let messages: Vec<_> = errors.into_iter().map(|e| e.detail).collect(); - bail!("got error from crates.io: {}", messages.join("\n")); - } + assert_eq!(result.crates, crates); + assert_eq!(result.meta, meta); + + Ok(()) + } - let Some(crates) = response.crates else { - bail!("missing releases in crates.io response"); + #[tokio::test] + async fn test_search_crates_missing() -> Result<()> { + let meta = SearchMeta { + next_page: Some("next".into()), + prev_page: Some("prev".into()), }; - let Some(meta) = response.meta else { - bail!("missing metadata in crates.io response"); + assert!(matches!( + test_search( + StatusCode::OK, + SearchResponse { + crates: None, + meta: Some(meta.clone()), + } + ) + .await + .unwrap_err(), + Error::MissingReleases + )); + + Ok(()) + } + + #[tokio::test] + async fn test_search_meta_missing() -> Result<()> { + let crates = vec![ + SearchCrate { name: "foo".into() }, + SearchCrate { name: "bar".into() }, + ]; + + assert!(matches!( + test_search( + StatusCode::OK, + SearchResponse { + crates: Some(crates.clone()), + meta: None, + } + ) + .await + .unwrap_err(), + Error::MissingMetadata + )); + + Ok(()) + } + + #[tokio::test] + #[test_case(StatusCode::BAD_REQUEST)] + #[test_case(StatusCode::UNAUTHORIZED)] + async fn test_search_new_style_api_errors(status: StatusCode) -> Result<()> { + let response = ApiErrors { + errors: vec![ + ApiError { + detail: Some("error 1".into()), + }, + ApiError { + detail: Some("error 2".into()), + }, + ], + }; + + assert!(matches!( + test_search(status, response.clone()).await.unwrap_err(), + Error::CrateIoApiError(status, errors) if errors == response + )); + + Ok(()) + } + + #[tokio::test] + #[test_case(StatusCode::INTERNAL_SERVER_ERROR)] + #[test_case(StatusCode::BAD_GATEWAY)] + async fn test_search_server_errors(status: StatusCode) -> Result<()> { + let msg = "some error message"; + + let err = test_search(status, msg).await.unwrap_err(); + assert!(err.to_string().contains(msg)); + assert_eq!(err.status(), Some(status)); + + let Error::HttpError(req_err, body) = err else { + panic!("Expected HttpError"); }; - Ok(Search { crates, meta }) + assert_eq!(req_err.status(), Some(status)); + assert!(body.contains(msg)); + + Ok(()) } } diff --git a/crates/lib/docs_rs_registry_api/src/error.rs b/crates/lib/docs_rs_registry_api/src/error.rs new file mode 100644 index 000000000..50cdd4607 --- /dev/null +++ b/crates/lib/docs_rs_registry_api/src/error.rs @@ -0,0 +1,88 @@ +use crate::models::ApiErrors; +use reqwest::StatusCode; + +pub(crate) type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Invalid API url")] + InvalidApiUrl, + #[error("API error from crates.io: {0}\n{1}")] + CrateIoApiError(StatusCode, ApiErrors), + #[error("Error from crates.io: {0}\n{1}")] + CrateIoError(StatusCode, String), + #[error("missing releases in crates.io response")] + MissingReleases, + #[error("missing metadata in crates.io response")] + MissingMetadata, + #[error("HTTP error: {0}\n{1}")] + HttpError(reqwest::Error, String), + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl Error { + /// return the HTTP status code of any error inside, if there is any. + pub fn status(&self) -> Option { + match self { + Self::CrateIoError(status, _) | Self::CrateIoApiError(status, _) => Some(*status), + Self::HttpError(error, _body) => error.status(), + _ => None, + } + } +} + +impl From for Error { + fn from(err: reqwest::Error) -> Self { + Self::HttpError(err, String::new()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + use reqwest::StatusCode; + + #[test] + fn test_error_without_status() { + for err in [ + Error::InvalidApiUrl, + Error::MissingReleases, + Error::MissingMetadata, + Error::Other(anyhow!("some error")), + ] { + assert!(err.status().is_none()); + } + } + + #[test] + fn test_error_with_included_status() { + let status = StatusCode::INTERNAL_SERVER_ERROR; + + assert!(Error::CrateIoApiError(status, ApiErrors::default()).status() == Some(status)); + + assert!(Error::CrateIoError(status, "".into()).status() == Some(status)); + } + + #[tokio::test] + async fn test_error_reqwest_error_status() -> Result<()> { + let status = StatusCode::INTERNAL_SERVER_ERROR; + + let mut srv = mockito::Server::new_async().await; + let _m = srv + .mock("GET", "/") + .with_status(status.as_u16().into()) + .create_async() + .await; + + let err = reqwest::get(&srv.url()) + .await? + .error_for_status() + .unwrap_err(); + + assert_eq!(err.status(), Some(status)); + + Ok(()) + } +} diff --git a/crates/lib/docs_rs_registry_api/src/lib.rs b/crates/lib/docs_rs_registry_api/src/lib.rs index e5c3a1fcd..1763fd6b2 100644 --- a/crates/lib/docs_rs_registry_api/src/lib.rs +++ b/crates/lib/docs_rs_registry_api/src/lib.rs @@ -1,7 +1,9 @@ mod api; mod config; +mod error; mod models; pub use api::RegistryApi; pub use config::Config; +pub use error::Error; pub use models::{CrateData, CrateOwner, OwnerKind, ReleaseData, Search}; diff --git a/crates/lib/docs_rs_registry_api/src/models.rs b/crates/lib/docs_rs_registry_api/src/models.rs index 89cfc4e8e..2c5ee5cf4 100644 --- a/crates/lib/docs_rs_registry_api/src/models.rs +++ b/crates/lib/docs_rs_registry_api/src/models.rs @@ -50,12 +50,21 @@ impl fmt::Display for OwnerKind { } } +#[derive(Deserialize, Debug, Default)] +#[cfg_attr(test, derive(Serialize))] +pub(crate) struct SearchResponse { + pub(crate) crates: Option>, + pub(crate) meta: Option, +} + #[derive(Deserialize, Debug)] +#[cfg_attr(test, derive(Serialize, PartialEq, Clone))] pub struct SearchCrate { pub name: String, } #[derive(Deserialize, Debug)] +#[cfg_attr(test, derive(Serialize, PartialEq, Clone))] pub struct SearchMeta { pub next_page: Option, pub prev_page: Option, @@ -66,3 +75,34 @@ pub struct Search { pub crates: Vec, pub meta: SearchMeta, } + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(test, derive(Serialize))] +pub struct ApiErrors { + pub errors: Vec, +} + +impl fmt::Display for ApiErrors { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for error in &self.errors { + writeln!(f, "{}", error)?; + } + Ok(()) + } +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(Serialize))] +pub struct ApiError { + pub detail: Option, +} + +impl fmt::Display for ApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + self.detail.as_deref().unwrap_or("Unknown API Error") + ) + } +} diff --git a/crates/lib/docs_rs_utils/src/lib.rs b/crates/lib/docs_rs_utils/src/lib.rs index 37429eebb..cde071992 100644 --- a/crates/lib/docs_rs_utils/src/lib.rs +++ b/crates/lib/docs_rs_utils/src/lib.rs @@ -107,9 +107,10 @@ pub fn retry(mut f: impl FnMut() -> Result, max_attempts: u32) -> Result Fut>(mut f: F, max_attempts: u32) -> Result +pub async fn retry_async Fut>(mut f: F, max_attempts: u32) -> Result where - Fut: Future>, + Fut: Future>, + E: fmt::Debug, { for attempt in 1.. { match f().await {