Skip to content

Commit 7a3b337

Browse files
committed
feat(http): retry external api 5xx with exponential backoff and per-attempt logs
1 parent 594ddb5 commit 7a3b337

11 files changed

Lines changed: 193 additions & 48 deletions

File tree

service/src/http/abstraction.rs

Lines changed: 133 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,99 @@
11
use crate::config::http::REQWEST_DEFAULT_USER_AGENT;
2-
use futures_util::future;
2+
use log::{error, warn};
3+
use rand::RngExt;
34
use reqwest::{IntoUrl, Request, RequestBuilder, Response};
5+
use std::time::Duration;
46
use tower::retry::Policy;
57

8+
pub const MAX_RETRIES: usize = 3;
9+
const BACKOFF_MS: &[u64] = &[250, 500, 1000];
10+
const JITTER_MAX_MS: u64 = 100;
11+
612
#[derive(Debug, Clone)]
7-
pub struct RetryPolicy(pub usize);
13+
pub struct RetryPolicy {
14+
provider: &'static str,
15+
remaining: usize,
16+
max: usize,
17+
}
818

9-
impl<E> Policy<Request, Response, E> for RetryPolicy {
10-
type Future = future::Ready<()>;
19+
impl RetryPolicy {
20+
pub fn new(provider: &'static str) -> Self {
21+
Self::with_max(provider, MAX_RETRIES)
22+
}
1123

12-
fn retry(&mut self, _: &mut Request, result: &mut Result<Response, E>) -> Option<Self::Future> {
13-
if self.0 == 0 {
14-
return None;
24+
pub fn with_max(provider: &'static str, max: usize) -> Self {
25+
Self {
26+
provider,
27+
remaining: max,
28+
max,
1529
}
30+
}
31+
}
32+
33+
#[derive(Debug, PartialEq, Eq)]
34+
enum RetryDecision {
35+
Skip,
36+
Retry { delay_ms: u64 },
37+
Exhausted,
38+
}
1639

17-
if result.is_err() {
18-
self.0 -= 1;
19-
Some(future::ready(()))
20-
} else if let Ok(res) = result {
21-
if res.status().is_server_error() {
22-
self.0 -= 1;
23-
Some(future::ready(()))
24-
} else {
40+
fn decide_retry(remaining: usize, max: usize, is_retryable: bool) -> RetryDecision {
41+
if !is_retryable {
42+
return RetryDecision::Skip;
43+
}
44+
if remaining == 0 {
45+
return RetryDecision::Exhausted;
46+
}
47+
let attempt_done = max - remaining + 1;
48+
let base = BACKOFF_MS[(attempt_done - 1).min(BACKOFF_MS.len() - 1)];
49+
let jitter: u64 = rand::rng().random_range(0..=JITTER_MAX_MS);
50+
RetryDecision::Retry {
51+
delay_ms: base + jitter,
52+
}
53+
}
54+
55+
impl<E: std::fmt::Display> Policy<Request, Response, E> for RetryPolicy {
56+
type Future = tokio::time::Sleep;
57+
58+
fn retry(
59+
&mut self,
60+
req: &mut Request,
61+
result: &mut Result<Response, E>,
62+
) -> Option<Self::Future> {
63+
let is_retryable = match result {
64+
Err(_) => true,
65+
Ok(res) => res.status().is_server_error(),
66+
};
67+
let attempt_done = self.max - self.remaining + 1;
68+
match decide_retry(self.remaining, self.max, is_retryable) {
69+
RetryDecision::Skip => None,
70+
RetryDecision::Exhausted => {
71+
let cause = match result {
72+
Err(e) => format!("network error: {e}"),
73+
Ok(res) => format!("HTTP {}", res.status().as_u16()),
74+
};
75+
error!(
76+
"{} request to {} failed after {} retries ({cause})",
77+
self.provider,
78+
req.url(),
79+
self.max
80+
);
2581
None
2682
}
27-
} else {
28-
None
83+
RetryDecision::Retry { delay_ms } => {
84+
let cause = match result {
85+
Err(e) => format!("network error: {e}"),
86+
Ok(res) => format!("HTTP {}", res.status().as_u16()),
87+
};
88+
warn!(
89+
"{} request to {} returned {cause}; retrying ({attempt_done}/{}) after {delay_ms}ms",
90+
self.provider,
91+
req.url(),
92+
self.max
93+
);
94+
self.remaining -= 1;
95+
Some(tokio::time::sleep(Duration::from_millis(delay_ms)))
96+
}
2997
}
3098
}
3199

@@ -34,6 +102,54 @@ impl<E> Policy<Request, Response, E> for RetryPolicy {
34102
}
35103
}
36104

105+
#[cfg(test)]
106+
mod tests {
107+
use super::*;
108+
109+
#[test]
110+
fn skips_when_not_retryable() {
111+
assert_eq!(decide_retry(3, 3, false), RetryDecision::Skip);
112+
}
113+
114+
#[test]
115+
fn exhausted_when_remaining_is_zero() {
116+
assert_eq!(decide_retry(0, 3, true), RetryDecision::Exhausted);
117+
}
118+
119+
#[test]
120+
fn retries_when_remaining_and_retryable() {
121+
match decide_retry(3, 3, true) {
122+
RetryDecision::Retry { delay_ms } => {
123+
assert!((BACKOFF_MS[0]..=BACKOFF_MS[0] + JITTER_MAX_MS).contains(&delay_ms));
124+
}
125+
other => panic!("expected Retry, got {other:?}"),
126+
}
127+
}
128+
129+
#[test]
130+
fn backoff_grows_with_attempts() {
131+
fn base_for(remaining: usize, max: usize) -> u64 {
132+
let attempt_done = max - remaining + 1;
133+
BACKOFF_MS[(attempt_done - 1).min(BACKOFF_MS.len() - 1)]
134+
}
135+
assert_eq!(base_for(3, 3), 250);
136+
assert_eq!(base_for(2, 3), 500);
137+
assert_eq!(base_for(1, 3), 1000);
138+
}
139+
140+
#[test]
141+
fn backoff_clamps_to_last_step_after_schedule_ends() {
142+
fn base_for(remaining: usize, max: usize) -> u64 {
143+
let attempt_done = max - remaining + 1;
144+
BACKOFF_MS[(attempt_done - 1).min(BACKOFF_MS.len() - 1)]
145+
}
146+
assert_eq!(base_for(4, 5), 500);
147+
assert_eq!(base_for(3, 5), 1000);
148+
assert_eq!(base_for(2, 5), 1000);
149+
assert_eq!(base_for(1, 5), 1000);
150+
}
151+
}
152+
37153
pub trait RequestClientExt {
38154
fn get_default_user_agent<U: IntoUrl>(&self, url: U) -> RequestBuilder;
39155
}

service/src/providers/emuready/mod.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ pub const API_URL: &str = "https://www.emuready.com/api/mobile/trpc";
2424

2525
const RATELIMIT_AMOUNT: u64 = 4;
2626
const RATELIMIT_DURATION_MS: u64 = 1000;
27-
const MAX_RETRIES: usize = 3;
2827
const SEARCH_LIMIT: i64 = 50;
2928

3029
pub struct EmuReadyClient {
@@ -43,7 +42,7 @@ impl EmuReadyClient {
4342
RATELIMIT_AMOUNT,
4443
Duration::from_millis(RATELIMIT_DURATION_MS),
4544
);
46-
let retry_layer = tower::retry::RetryLayer::new(RetryPolicy(MAX_RETRIES));
45+
let retry_layer = tower::retry::RetryLayer::new(RetryPolicy::new("emuready"));
4746

4847
let service = ServiceBuilder::new()
4948
.layer(rate_limit_layer)

service/src/providers/igdb/constants.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
pub const API_URL: &str = "https://api.igdb.com/v4";
22

3-
pub const IGDB_MAX_RETRIES: usize = 3;
4-
53
pub const IGDB_RATELIMIT_AMOUNT: u64 = 4;
64
pub const IGDB_RATELIMIT_DURATION_MS: u64 = 1000;
75

service/src/providers/igdb/mod.rs

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
use crate::config::http::REQWEST_DEFAULT_USER_AGENT;
22
use crate::http::abstraction::RetryPolicy;
33
use crate::providers::igdb::constants::{
4-
API_URL, IGDB_MAX_RETRIES, IGDB_RATELIMIT_AMOUNT, IGDB_RATELIMIT_DURATION_MS,
5-
IGDB_ROUTE_AGE_RATING_CATEGORIES, IGDB_ROUTE_AGE_RATING_CONTENT_DESCRIPTION_TYPES,
6-
IGDB_ROUTE_AGE_RATING_CONTENT_DESCRIPTIONS_V2, IGDB_ROUTE_AGE_RATING_ORGANIZATIONS,
7-
IGDB_ROUTE_AGE_RATINGS, IGDB_ROUTE_ALTERNATIVE_NAMES, IGDB_ROUTE_ARTWORK_TYPES,
8-
IGDB_ROUTE_ARTWORKS, IGDB_ROUTE_CHARACTER_GENDERS, IGDB_ROUTE_CHARACTER_MUG_SHOTS,
9-
IGDB_ROUTE_CHARACTER_SPECIES, IGDB_ROUTE_CHARACTERS, IGDB_ROUTE_COLLECTION_MEMBERSHIP_TYPES,
10-
IGDB_ROUTE_COLLECTION_MEMBERSHIPS, IGDB_ROUTE_COLLECTION_RELATION_TYPES,
11-
IGDB_ROUTE_COLLECTION_RELATIONS, IGDB_ROUTE_COLLECTION_TYPES, IGDB_ROUTE_COLLECTIONS,
12-
IGDB_ROUTE_COMPANIES, IGDB_ROUTE_COMPANY_LOGOS, IGDB_ROUTE_COMPANY_SIZES,
13-
IGDB_ROUTE_COMPANY_STATUSES, IGDB_ROUTE_COMPANY_TYPE_HISTORIES, IGDB_ROUTE_COMPANY_TYPES,
14-
IGDB_ROUTE_COMPANY_WEBSITES, IGDB_ROUTE_COVERS, IGDB_ROUTE_DATE_FORMATS,
15-
IGDB_ROUTE_ENTITY_TYPES, IGDB_ROUTE_EVENT_LOGOS, IGDB_ROUTE_EVENT_NETWORKS, IGDB_ROUTE_EVENTS,
16-
IGDB_ROUTE_EXTERNAL_GAME_SOURCES, IGDB_ROUTE_EXTERNAL_GAMES, IGDB_ROUTE_FRANCHISES,
17-
IGDB_ROUTE_GAME_ENGINE_LOGOS, IGDB_ROUTE_GAME_ENGINES, IGDB_ROUTE_GAME_LOCALIZATIONS,
18-
IGDB_ROUTE_GAME_MODES, IGDB_ROUTE_GAME_RELEASE_FORMATS, IGDB_ROUTE_GAME_STATUSES,
19-
IGDB_ROUTE_GAME_TIME_TO_BEATS, IGDB_ROUTE_GAME_TYPES, IGDB_ROUTE_GAME_VERSION_FEATURE_VALUES,
4+
API_URL, IGDB_RATELIMIT_AMOUNT, IGDB_RATELIMIT_DURATION_MS, IGDB_ROUTE_AGE_RATING_CATEGORIES,
5+
IGDB_ROUTE_AGE_RATING_CONTENT_DESCRIPTION_TYPES, IGDB_ROUTE_AGE_RATING_CONTENT_DESCRIPTIONS_V2,
6+
IGDB_ROUTE_AGE_RATING_ORGANIZATIONS, IGDB_ROUTE_AGE_RATINGS, IGDB_ROUTE_ALTERNATIVE_NAMES,
7+
IGDB_ROUTE_ARTWORK_TYPES, IGDB_ROUTE_ARTWORKS, IGDB_ROUTE_CHARACTER_GENDERS,
8+
IGDB_ROUTE_CHARACTER_MUG_SHOTS, IGDB_ROUTE_CHARACTER_SPECIES, IGDB_ROUTE_CHARACTERS,
9+
IGDB_ROUTE_COLLECTION_MEMBERSHIP_TYPES, IGDB_ROUTE_COLLECTION_MEMBERSHIPS,
10+
IGDB_ROUTE_COLLECTION_RELATION_TYPES, IGDB_ROUTE_COLLECTION_RELATIONS,
11+
IGDB_ROUTE_COLLECTION_TYPES, IGDB_ROUTE_COLLECTIONS, IGDB_ROUTE_COMPANIES,
12+
IGDB_ROUTE_COMPANY_LOGOS, IGDB_ROUTE_COMPANY_SIZES, IGDB_ROUTE_COMPANY_STATUSES,
13+
IGDB_ROUTE_COMPANY_TYPE_HISTORIES, IGDB_ROUTE_COMPANY_TYPES, IGDB_ROUTE_COMPANY_WEBSITES,
14+
IGDB_ROUTE_COVERS, IGDB_ROUTE_DATE_FORMATS, IGDB_ROUTE_ENTITY_TYPES, IGDB_ROUTE_EVENT_LOGOS,
15+
IGDB_ROUTE_EVENT_NETWORKS, IGDB_ROUTE_EVENTS, IGDB_ROUTE_EXTERNAL_GAME_SOURCES,
16+
IGDB_ROUTE_EXTERNAL_GAMES, IGDB_ROUTE_FRANCHISES, IGDB_ROUTE_GAME_ENGINE_LOGOS,
17+
IGDB_ROUTE_GAME_ENGINES, IGDB_ROUTE_GAME_LOCALIZATIONS, IGDB_ROUTE_GAME_MODES,
18+
IGDB_ROUTE_GAME_RELEASE_FORMATS, IGDB_ROUTE_GAME_STATUSES, IGDB_ROUTE_GAME_TIME_TO_BEATS,
19+
IGDB_ROUTE_GAME_TYPES, IGDB_ROUTE_GAME_VERSION_FEATURE_VALUES,
2020
IGDB_ROUTE_GAME_VERSION_FEATURES, IGDB_ROUTE_GAME_VERSIONS, IGDB_ROUTE_GAME_VIDEOS,
2121
IGDB_ROUTE_GAMES, IGDB_ROUTE_GENRES, IGDB_ROUTE_INVOLVED_COMPANIES, IGDB_ROUTE_KEYWORDS,
2222
IGDB_ROUTE_LANGUAGE_SUPPORT_TYPES, IGDB_ROUTE_LANGUAGE_SUPPORTS, IGDB_ROUTE_LANGUAGES,
@@ -107,7 +107,7 @@ impl IgdbClient {
107107
IGDB_RATELIMIT_AMOUNT,
108108
Duration::from_millis(IGDB_RATELIMIT_DURATION_MS),
109109
);
110-
let retry_layer = tower::retry::RetryLayer::new(RetryPolicy(IGDB_MAX_RETRIES));
110+
let retry_layer = tower::retry::RetryLayer::new(RetryPolicy::new("igdb"));
111111

112112
let service = ServiceBuilder::new()
113113
.layer(rate_limit_layer)

service/src/providers/mobygames/mod.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ pub const API_URL: &str = "https://api.mobygames.com/v1";
2626
/// Cheapest MobyGames tier permits one request every five seconds.
2727
const RATELIMIT_AMOUNT: u64 = 1;
2828
const RATELIMIT_DURATION_MS: u64 = 5000;
29-
const MAX_RETRIES: usize = 3;
3029

3130
pub struct MobyGamesClient {
3231
client: Client,
@@ -47,7 +46,7 @@ impl MobyGamesClient {
4746
RATELIMIT_AMOUNT,
4847
Duration::from_millis(RATELIMIT_DURATION_MS),
4948
);
50-
let retry_layer = tower::retry::RetryLayer::new(RetryPolicy(MAX_RETRIES));
49+
let retry_layer = tower::retry::RetryLayer::new(RetryPolicy::new("mobygames"));
5150

5251
let service = ServiceBuilder::new()
5352
.layer(rate_limit_layer)

service/src/providers/retroachievements/api.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ async fn fetch_text(
5757
url: Url,
5858
endpoint_label: &'static str,
5959
) -> anyhow::Result<String> {
60-
let response = client.http().get(url).send().await?;
60+
let req = client.http().get(url).build()?;
61+
let response = client.execute(req).await?;
6162
let status = response.status();
6263
let body = response.text().await?;
6364
match status {

service/src/providers/retroachievements/mod.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
use crate::http::abstraction::RetryPolicy;
12
use entity::sea_orm_active_enums::MetadataProviderEnum;
2-
use reqwest::Client;
3+
use reqwest::{Client, Request, Response};
34
use sea_orm::DbConn;
45
use std::sync::Arc;
6+
use tokio::sync::Mutex;
7+
use tower::retry::Retry;
8+
use tower::{Service, ServiceBuilder, ServiceExt};
59

610
pub mod api;
711
pub mod cache;
@@ -13,6 +17,7 @@ pub const API_BASE: &str = "https://retroachievements.org/API";
1317

1418
pub struct RetroAchievementsClient {
1519
http: Client,
20+
service: Mutex<Retry<RetryPolicy, Client>>,
1621
redis_conn: redis::aio::MultiplexedConnection,
1722
db_conn: DbConn,
1823
username: String,
@@ -28,8 +33,14 @@ impl RetroAchievementsClient {
2833
redis_conn: redis::aio::MultiplexedConnection,
2934
db_conn: DbConn,
3035
) -> anyhow::Result<Self> {
36+
let retry_layer = tower::retry::RetryLayer::new(RetryPolicy::new("retroachievements"));
37+
let service = ServiceBuilder::new()
38+
.layer(retry_layer)
39+
.service(http.clone());
40+
3141
Ok(Self {
3242
http,
43+
service: Mutex::new(service),
3344
redis_conn,
3445
db_conn,
3546
username,
@@ -38,6 +49,12 @@ impl RetroAchievementsClient {
3849
})
3950
}
4051

52+
pub(crate) async fn execute(&self, req: Request) -> reqwest::Result<Response> {
53+
let mut svc = self.service.lock().await;
54+
let svc = svc.ready().await?;
55+
svc.call(req).await
56+
}
57+
4158
pub async fn ensure_imported(&self) -> anyhow::Result<import::ImportOutcome> {
4259
import::ensure_imported(self).await
4360
}

service/src/providers/screenscraper/mod.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ const SOFTNAME: &str = "playmatch";
3737
/// as abusive; we apply it per-permit so concurrent threads each pace
3838
/// themselves rather than sharing a single global token bucket.
3939
const POST_REQUEST_DELAY_MS: u64 = 1200;
40-
const MAX_RETRIES: usize = 3;
4140

4241
/// Hard ceiling on the concurrency probed from `ssuser.maxthreads`.
4342
const MAX_CONCURRENCY: usize = 16;
@@ -122,7 +121,7 @@ impl ScreenScraperClient {
122121
client: Client,
123122
redis_conn: redis::aio::MultiplexedConnection,
124123
) -> anyhow::Result<Self> {
125-
let retry_layer = tower::retry::RetryLayer::new(RetryPolicy(MAX_RETRIES));
124+
let retry_layer = tower::retry::RetryLayer::new(RetryPolicy::new("screenscraper"));
126125

127126
let service = ServiceBuilder::new()
128127
.layer(retry_layer)

service/src/providers/steamgriddb/mod.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ pub const API_URL: &str = "https://www.steamgriddb.com/api/v2";
2424

2525
const RATELIMIT_AMOUNT: u64 = 8;
2626
const RATELIMIT_DURATION_MS: u64 = 1000;
27-
const MAX_RETRIES: usize = 3;
2827

2928
pub struct SteamGridDbClient {
3029
client: Client,
@@ -43,7 +42,7 @@ impl SteamGridDbClient {
4342
RATELIMIT_AMOUNT,
4443
Duration::from_millis(RATELIMIT_DURATION_MS),
4544
);
46-
let retry_layer = tower::retry::RetryLayer::new(RetryPolicy(MAX_RETRIES));
45+
let retry_layer = tower::retry::RetryLayer::new(RetryPolicy::new("steamgriddb"));
4746

4847
let service = ServiceBuilder::new()
4948
.layer(rate_limit_layer)

service/src/providers/thegamesdb/api.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ impl TheGamesDbClient {
177177

178178
let started = Instant::now();
179179
let result: anyhow::Result<T> = async {
180-
let res = self.http.execute(req).await?;
180+
let res = self.execute(req).await?;
181181
let status = res.status();
182182
let body = res.text().await?;
183183
if !status.is_success() {

0 commit comments

Comments
 (0)