Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ denylist = [
"_semver",
]

[dev-dependencies]
hyper = { version = "1", features = ["server", "http1"] }
hyper-util = { version = "0.1", features = ["tokio"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net"] }

[package.metadata.release]
enable-features = ["sequoia-openpgp/crypto-nettle"]
tag = true
5 changes: 5 additions & 0 deletions common/src/cli/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@ pub struct ClientArguments {
/// Per-request retries count
#[arg(short, long, default_value = "5")]
pub retries: usize,

/// Per-request minimum delay after rate limit (429).
#[arg(long, default_value = "10s")]
pub default_retry_after: humantime::Duration,
}

impl From<ClientArguments> for FetcherOptions {
fn from(value: ClientArguments) -> Self {
FetcherOptions {
timeout: value.timeout.into(),
retries: value.retries,
default_retry_after: value.default_retry_after.into(),
}
}
}
Expand Down
36 changes: 36 additions & 0 deletions common/src/fetcher/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod data;
use backon::{ExponentialBuilder, Retryable};
pub use data::*;

use crate::http::get_retry_after_from_response_header;
use reqwest::{Client, ClientBuilder, IntoUrl, Method, Response};
use std::fmt::Debug;
use std::future::Future;
Expand All @@ -15,17 +16,21 @@ use url::Url;
///
/// This is some functionality sitting on top an HTTP client, allowing for additional options like
/// retries.
/// *default_retry_after* is used when a 429 response does not include a Retry-After header.
#[derive(Clone, Debug)]
pub struct Fetcher {
client: Client,
retries: usize,
default_retry_after: Duration,
}

/// Error when retrieving
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Request error: {0}")]
Request(#[from] reqwest::Error),
#[error("Rate limited (HTTP 429), retry after {0:?}")]
RateLimited(Duration),
}

/// Options for the [`Fetcher`]
Expand All @@ -34,6 +39,7 @@ pub enum Error {
pub struct FetcherOptions {
pub timeout: Duration,
pub retries: usize,
pub default_retry_after: Duration,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, in addition to the default value, we should also have a "max" value. Otherwise it might happen, malicious or not, that someone asks for "wait 1 year", and we would do.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But we wouldn't expose the "max" value to the user. What could be a reasonable value for "max"? Or better a maximum value for this limit? 5 minutes? Would be long enough as I don't think any API should have a much longer rate limiting. But if it has there are other problems.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should. You never know what a user wants. But I also think we should offer a reasonable default, like you suggested, 5 mins.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we expose MAX to the user, we have to have a limit for that and document it. Otherwise we will have the same problem if the user sets default and max to 1year.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I'd say we leave this unlimited. If the user puts in 1 year, that's the user's choice. However, the user is not able to influence the source. So this enables the user to take control of limits.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, let's leave it open with 5min as default. I'll add that.

}

impl FetcherOptions {
Expand All @@ -53,13 +59,20 @@ impl FetcherOptions {
self.retries = retries;
self
}

/// Set the default retry-after duration when a 429 response doesn't include a Retry-After header.
pub fn default_retry_after(mut self, duration: impl Into<Duration>) -> Self {
self.default_retry_after = duration.into();
self
}
}

impl Default for FetcherOptions {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
retries: 5,
default_retry_after: Duration::from_secs(10),
}
}
}
Expand All @@ -83,6 +96,7 @@ impl Fetcher {
Self {
client,
retries: options.retries,
default_retry_after: options.default_retry_after,
}
}

Expand Down Expand Up @@ -122,6 +136,20 @@ impl Fetcher {
}
})
.retry(&backoff.with_max_times(retries))
.notify(|err, dur| {
// If rate limited, ensure we wait at least the Retry-After duration
if let Error::RateLimited(retry_after) = err {
if dur < *retry_after {
log::info!(
"Rate limited, extending wait from {:?} to {:?}",
dur,
retry_after
);
let additional = *retry_after - dur;
std::thread::sleep(additional);
}
}
})
.await
}

Expand All @@ -134,6 +162,14 @@ impl Fetcher {

log::debug!("Response Status: {}", response.status());

// Check for rate limiting
if let Some(retry_after) =
get_retry_after_from_response_header(&response, self.default_retry_after)
{
log::warn!("Rate limited (429), retry after: {:?}", retry_after);
return Err(Error::RateLimited(retry_after));
}

Ok(processor.process(response).await?)
}
}
Expand Down
29 changes: 29 additions & 0 deletions common/src/http.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use std::time::Duration;

use reqwest::{Response, StatusCode, header};

/// Parse Retry-After header value
fn parse_retry_after(value: &str) -> Option<Duration> {
// Try parsing as seconds (numeric)
if let Ok(seconds) = value.parse::<u64>() {
return Some(Duration::from_secs(seconds));
}
// Could also parse HTTP-date format here if needed
None
}

pub fn get_retry_after_from_response_header(
response: &Response,
default_duration: Duration,
) -> Option<Duration> {
if response.status() == StatusCode::TOO_MANY_REQUESTS {
let retry_after = response
.headers()
.get(header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(parse_retry_after)
.unwrap_or(default_duration);
return Some(retry_after);
}
None
}
1 change: 1 addition & 0 deletions common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
pub mod changes;
pub mod compression;
pub mod fetcher;
pub mod http;
pub mod locale;
pub mod progress;
pub mod report;
Expand Down
Loading