-
Notifications
You must be signed in to change notification settings - Fork 9
feat: handle rate limiting #71
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
Changes from 1 commit
b1a2612
3a7b20b
db0159b
900c683
56939e1
b7d4ce9
5db7fcd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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`] | ||
|
|
@@ -34,6 +39,7 @@ pub enum Error { | |
| pub struct FetcherOptions { | ||
| pub timeout: Duration, | ||
| pub retries: usize, | ||
| pub default_retry_after: Duration, | ||
|
||
| } | ||
|
|
||
| impl FetcherOptions { | ||
|
|
@@ -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), | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -83,6 +96,7 @@ impl Fetcher { | |
| Self { | ||
| client, | ||
| retries: options.retries, | ||
| default_retry_after: options.default_retry_after, | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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; | ||
sourcery-ai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| std::thread::sleep(additional); | ||
| } | ||
| } | ||
| }) | ||
| .await | ||
| } | ||
|
|
||
|
|
@@ -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); | ||
tziemek marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return Err(Error::RateLimited(retry_after)); | ||
| } | ||
|
|
||
| Ok(processor.process(response).await?) | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
tziemek marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.