use std::time::Duration;
use ureq::Agent;
use super::error::BackendError;
use super::types::BackendId;
pub const USER_AGENT: &str = concat!(
"rekord-ripper/",
env!("CARGO_PKG_VERSION"),
" (+https://github.com/ImTheSquid/rekord-ripper)"
);
const PER_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
pub fn agent(budget: Duration) -> Agent {
let per_request = budget.min(PER_REQUEST_TIMEOUT);
Agent::config_builder()
.timeout_global(Some(per_request))
.user_agent(USER_AGENT)
.build()
.into()
}
pub fn download_agent(budget: Duration) -> Agent {
Agent::config_builder()
.timeout_global(Some(budget))
.user_agent(USER_AGENT)
.build()
.into()
}
pub fn map_err(backend: BackendId, url: &str, e: ureq::Error) -> BackendError {
match e {
ureq::Error::StatusCode(status) => {
if status == 429 {
BackendError::RateLimited {
backend,
retry_after: None,
}
} else {
BackendError::Http {
backend,
status,
url: url.to_string(),
}
}
}
ureq::Error::Timeout(_) => BackendError::Timeout {
backend,
op: "http",
elapsed: PER_REQUEST_TIMEOUT,
},
other => BackendError::Network {
backend,
detail: other.to_string(),
},
}
}
pub fn with_retries<T>(
attempts: u32,
mut op: impl FnMut() -> Result<T, BackendError>,
) -> Result<T, BackendError> {
let mut delay = Duration::from_millis(500);
let mut last = None;
for attempt in 0..attempts.max(1) {
match op() {
Ok(v) => return Ok(v),
Err(e) if e.is_retryable() && attempt + 1 < attempts => {
std::thread::sleep(e.retry_after().unwrap_or(delay));
delay = (delay * 2).min(Duration::from_secs(8));
last = Some(e);
}
Err(e) => return Err(e),
}
}
Err(last.expect("loop ran at least once and did not return"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
#[test]
fn user_agent_identifies_the_tool_and_version() {
assert!(USER_AGENT.starts_with("rekord-ripper/"));
assert!(USER_AGENT.contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn agents_build_with_a_bounded_timeout() {
let _ = agent(Duration::from_secs(10));
let _ = download_agent(Duration::from_secs(600));
}
#[test]
fn too_many_requests_becomes_rate_limited_not_a_bare_http_error() {
let e = map_err(BackendId::Bandcamp, "u", ureq::Error::StatusCode(429));
assert!(matches!(e, BackendError::RateLimited { .. }));
assert!(e.is_retryable());
}
#[test]
fn other_statuses_keep_their_code_and_url() {
let e = map_err(
BackendId::Bandcamp,
"https://x/y",
ureq::Error::StatusCode(404),
);
match e {
BackendError::Http { status, url, .. } => {
assert_eq!(status, 404);
assert_eq!(url, "https://x/y");
}
other => panic!("expected Http, got {other:?}"),
}
}
#[test]
fn retries_a_transient_failure_then_succeeds() {
let calls = Cell::new(0);
let got = with_retries(3, || {
calls.set(calls.get() + 1);
if calls.get() < 3 {
Err(BackendError::Network {
backend: BackendId::Bandcamp,
detail: "flaky".into(),
})
} else {
Ok(7)
}
})
.unwrap();
assert_eq!(got, 7);
assert_eq!(calls.get(), 3);
}
#[test]
fn does_not_retry_a_permanent_failure() {
let calls = Cell::new(0);
let err = with_retries(5, || {
calls.set(calls.get() + 1);
Err::<(), _>(BackendError::parse(BackendId::Bandcamp, "pagedata", "gone"))
})
.unwrap_err();
assert!(matches!(err, BackendError::Parse { .. }));
assert_eq!(calls.get(), 1, "a changed page shape must not be retried");
}
#[test]
fn gives_up_after_the_attempt_budget() {
let calls = Cell::new(0);
let err = with_retries(3, || {
calls.set(calls.get() + 1);
Err::<(), _>(BackendError::Network {
backend: BackendId::Bandcamp,
detail: "down".into(),
})
})
.unwrap_err();
assert!(matches!(err, BackendError::Network { .. }));
assert_eq!(calls.get(), 3);
}
#[test]
fn a_zero_attempt_budget_still_runs_once() {
let calls = Cell::new(0);
let _ = with_retries(0, || {
calls.set(calls.get() + 1);
Ok::<_, BackendError>(())
});
assert_eq!(calls.get(), 1);
}
}