use crate::errors::{Error, Result};
use crate::http_client;
pub(crate) mod common;
pub mod custom;
pub mod gitea;
pub mod github;
pub mod gitlab;
pub mod s3;
pub(crate) fn find_rel_next_link(link_str: &str) -> Option<&str> {
for link in link_str.split(',') {
let mut uri = None;
let mut is_rel_next = false;
for part in link.split(';') {
let part = part.trim();
if part.starts_with('<') && part.ends_with('>') {
uri = Some(part.trim_start_matches('<').trim_end_matches('>'));
} else if part.starts_with("rel=") {
let part = part
.trim_start_matches("rel=")
.trim_end_matches('"')
.trim_start_matches('"');
if part == "next" {
is_rel_next = true;
}
}
if is_rel_next && uri.is_some() {
return uri;
}
}
}
None
}
pub(crate) const MAX_RELEASE_PAGES: usize = 100;
pub(crate) fn first_page_url(base_url: &str) -> String {
if base_url.contains('?') {
base_url.to_owned()
} else {
format!("{base_url}?per_page=100")
}
}
pub(crate) fn next_link(headers: &http_client::HeaderMap) -> Option<String> {
headers
.get_all(http_client::header::LINK)
.iter()
.filter_map(|link| link.to_str().ok().and_then(find_rel_next_link))
.next()
.map(str::to_owned)
}
pub(crate) fn collect_paginated<T>(
first_url: &str,
mut fetch_page: impl FnMut(&str) -> Result<(Vec<T>, Option<String>)>,
) -> Result<Vec<T>> {
let mut out = Vec::new();
let mut next = Some(first_url.to_owned());
let mut pages = 0usize;
while let Some(url) = next {
let (items, next_url) = fetch_page(&url)?;
out.extend(items);
pages += 1;
if pages >= MAX_RELEASE_PAGES {
if next_url.is_some() {
log::warn!(
"self_update: stopped paginating releases after {MAX_RELEASE_PAGES} pages; \
older releases may be omitted"
);
}
break;
}
next = next_url;
}
Ok(out)
}
pub(crate) fn retry_backoff_ms(attempt: u32) -> u64 {
100u64 << attempt.min(5)
}
pub(crate) fn retry<R>(
retries: u32,
mut attempt: impl FnMut() -> Result<R>,
mut on_retry: impl FnMut(&Error, u64),
) -> Result<R> {
let mut attempts = 0u32;
loop {
match attempt() {
Ok(r) => return Ok(r),
Err(e) => {
if attempts >= retries {
return Err(e);
}
on_retry(&e, retry_backoff_ms(attempts));
attempts += 1;
}
}
}
}
#[cfg(feature = "async")]
pub(crate) async fn retry_async<R, A, Fut, S, SFut>(
retries: u32,
mut attempt: A,
mut log_retry: impl FnMut(&Error, u64),
mut sleep: S,
) -> Result<R>
where
A: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<R>>,
S: FnMut(u64) -> SFut,
SFut: std::future::Future<Output = ()>,
{
let mut attempts = 0u32;
loop {
match attempt().await {
Ok(r) => return Ok(r),
Err(e) => {
if attempts >= retries {
return Err(e);
}
let backoff = retry_backoff_ms(attempts);
log_retry(&e, backoff);
sleep(backoff).await;
attempts += 1;
}
}
}
}
pub(crate) fn send(
url: &str,
mut base: http_client::HeaderMap,
config: &common::RequestConfig,
) -> Result<impl http_client::HttpResponse> {
for (name, value) in &config.headers {
base.insert(name.clone(), value.clone());
}
retry(
config.retries,
|| http_client::get(url, base.clone(), config.timeout, &config.client),
|e, backoff| {
log::warn!("self_update: request to {url} failed ({e}); retrying in {backoff}ms");
std::thread::sleep(std::time::Duration::from_millis(backoff));
},
)
}
#[cfg(feature = "async")]
pub(crate) async fn send_async(
url: &str,
mut base: http_client::HeaderMap,
config: &common::RequestConfig,
) -> Result<http_client::AsyncResponse> {
for (name, value) in &config.headers {
base.insert(name.clone(), value.clone());
}
retry_async(
config.retries,
|| http_client::get_async(url, base.clone(), config.timeout, &config.client),
|e, backoff| {
log::warn!("self_update: request to {url} failed ({e}); retrying in {backoff}ms");
},
|backoff| tokio::time::sleep(std::time::Duration::from_millis(backoff)),
)
.await
}
#[cfg(feature = "async")]
pub(crate) async fn collect_paginated_async<T, F, Fut>(
first_url: &str,
mut fetch_page: F,
) -> Result<Vec<T>>
where
F: FnMut(String) -> Fut,
Fut: std::future::Future<Output = Result<(Vec<T>, Option<String>)>>,
{
let mut out = Vec::new();
let mut next = Some(first_url.to_owned());
let mut pages = 0usize;
while let Some(url) = next {
let (items, next_url) = fetch_page(url).await?;
out.extend(items);
pages += 1;
if pages >= MAX_RELEASE_PAGES {
if next_url.is_some() {
log::warn!(
"self_update: stopped paginating releases after {MAX_RELEASE_PAGES} pages; \
older releases may be omitted"
);
}
break;
}
next = next_url;
}
Ok(out)
}
#[cfg(test)]
mod test {
use crate::backends::find_rel_next_link;
#[test]
fn test_find_rel_link() {
let val = r##" <https://api.github.com/resource?page=2>; rel="next" "##;
let link = find_rel_next_link(val);
assert_eq!(link, Some("https://api.github.com/resource?page=2"));
let val = r##" <https://gitlab.com/api/v4/projects/13083/releases?id=13083&page=2&per_page=20>; rel="next" "##;
let link = find_rel_next_link(val);
assert_eq!(
link,
Some("https://gitlab.com/api/v4/projects/13083/releases?id=13083&page=2&per_page=20")
);
let val = r##" <https://place.com>; rel="next", <https://wow.com>; rel="next" "##;
let link = find_rel_next_link(val);
assert_eq!(link, Some("https://place.com"));
let val = r##" https://bad-format.com; rel="next", <https://wow.com>; rel="next" "##;
let link = find_rel_next_link(val);
assert_eq!(link, Some("https://wow.com"));
let val = r##" https://bad-format.com; rel="next", <https://also-bad.com; rel="next" , <https://good.com>; rel="preconnect" "##;
let link = find_rel_next_link(val);
assert!(link.is_none());
}
#[test]
fn collect_paginated_accumulates_pages() {
use crate::backends::collect_paginated;
let mut pages = vec![
(vec![1, 2], Some("page2".to_string())),
(vec![3], Some("page3".to_string())),
(vec![4, 5], None),
]
.into_iter();
let visited = std::cell::RefCell::new(Vec::new());
let got = collect_paginated::<i32>("page1", |url| {
visited.borrow_mut().push(url.to_string());
Ok(pages.next().unwrap())
})
.unwrap();
assert_eq!(got, vec![1, 2, 3, 4, 5]);
assert_eq!(*visited.borrow(), vec!["page1", "page2", "page3"]);
}
#[test]
fn collect_paginated_is_bounded_by_max_pages() {
use crate::backends::{collect_paginated, MAX_RELEASE_PAGES};
let mut calls = 0usize;
let got = collect_paginated::<i32>("start", |_url| {
calls += 1;
Ok((vec![0], Some("next".to_string())))
})
.unwrap();
assert_eq!(calls, MAX_RELEASE_PAGES);
assert_eq!(got.len(), MAX_RELEASE_PAGES);
}
#[test]
fn collect_paginated_single_page() {
use crate::backends::collect_paginated;
let mut calls = 0usize;
let got = collect_paginated::<i32>("only", |_url| {
calls += 1;
Ok((vec![7, 8, 9], None))
})
.unwrap();
assert_eq!(calls, 1);
assert_eq!(got, vec![7, 8, 9]);
}
#[test]
fn collect_paginated_propagates_fetch_error() {
use crate::backends::collect_paginated;
use crate::errors::Error;
let res: crate::errors::Result<Vec<i32>> = collect_paginated("u", |_url| {
Err(Error::HttpStatus {
status: 503,
url: "u".into(),
})
});
assert!(matches!(res, Err(Error::HttpStatus { .. })));
}
#[test]
fn retry_runs_once_on_immediate_success() {
use crate::backends::retry;
use std::cell::{Cell, RefCell};
let calls = Cell::new(0u32);
let backoffs = RefCell::new(Vec::<u64>::new());
let res: crate::errors::Result<i32> = retry(
3,
|| {
calls.set(calls.get() + 1);
Ok(7)
},
|_e, b| backoffs.borrow_mut().push(b),
);
assert_eq!(res.unwrap(), 7);
assert_eq!(calls.get(), 1);
assert!(backoffs.borrow().is_empty());
}
#[test]
fn retry_with_zero_budget_attempts_once_then_errors() {
use crate::backends::retry;
use crate::errors::Error;
use std::cell::{Cell, RefCell};
let calls = Cell::new(0u32);
let backoffs = RefCell::new(Vec::<u64>::new());
let res: crate::errors::Result<i32> = retry(
0,
|| {
calls.set(calls.get() + 1);
Err(Error::HttpStatus {
status: 503,
url: "u".into(),
})
},
|_e, b| backoffs.borrow_mut().push(b),
);
assert!(matches!(res, Err(Error::HttpStatus { .. })));
assert_eq!(calls.get(), 1);
assert!(backoffs.borrow().is_empty());
}
#[test]
fn retry_exhausts_budget_then_returns_last_error() {
use crate::backends::retry;
use crate::errors::Error;
use std::cell::{Cell, RefCell};
let calls = Cell::new(0u32);
let backoffs = RefCell::new(Vec::<u64>::new());
let res: crate::errors::Result<i32> = retry(
2,
|| {
calls.set(calls.get() + 1);
Err(Error::HttpStatus {
status: 503,
url: "u".into(),
})
},
|_e, b| backoffs.borrow_mut().push(b),
);
assert!(matches!(res, Err(Error::HttpStatus { .. })));
assert_eq!(calls.get(), 3);
assert_eq!(*backoffs.borrow(), vec![100, 200]);
}
#[test]
fn retry_returns_ok_when_a_later_attempt_succeeds() {
use crate::backends::retry;
use crate::errors::Error;
use std::cell::{Cell, RefCell};
let calls = Cell::new(0u32);
let backoffs = RefCell::new(Vec::<u64>::new());
let res: crate::errors::Result<i32> = retry(
5,
|| {
calls.set(calls.get() + 1);
if calls.get() < 3 {
Err(Error::HttpStatus {
status: 503,
url: "u".into(),
})
} else {
Ok(42)
}
},
|_e, b| backoffs.borrow_mut().push(b),
);
assert_eq!(res.unwrap(), 42);
assert_eq!(calls.get(), 3);
assert_eq!(*backoffs.borrow(), vec![100, 200]);
}
#[test]
fn retry_backoff_is_exponential_and_capped() {
use crate::backends::retry_backoff_ms;
assert_eq!(retry_backoff_ms(0), 100);
assert_eq!(retry_backoff_ms(1), 200);
assert_eq!(retry_backoff_ms(2), 400);
assert_eq!(retry_backoff_ms(3), 800);
assert_eq!(retry_backoff_ms(4), 1600);
assert_eq!(retry_backoff_ms(5), 3200);
assert_eq!(retry_backoff_ms(6), 3200);
assert_eq!(retry_backoff_ms(100), 3200);
}
#[test]
fn retry_with_a_single_retry_attempts_twice() {
use crate::backends::retry;
use crate::errors::Error;
use std::cell::{Cell, RefCell};
let calls = Cell::new(0u32);
let backoffs = RefCell::new(Vec::<u64>::new());
let res: crate::errors::Result<i32> = retry(
1,
|| {
calls.set(calls.get() + 1);
Err(Error::HttpStatus {
status: 503,
url: "u".into(),
})
},
|_e, b| backoffs.borrow_mut().push(b),
);
assert!(matches!(res, Err(Error::HttpStatus { .. })));
assert_eq!(calls.get(), 2);
assert_eq!(*backoffs.borrow(), vec![100]);
}
#[test]
fn retry_backoff_sequence_through_the_loop_climbs_and_caps() {
use crate::backends::retry;
use crate::errors::Error;
use std::cell::{Cell, RefCell};
let calls = Cell::new(0u32);
let backoffs = RefCell::new(Vec::<u64>::new());
let res: crate::errors::Result<i32> = retry(
6,
|| {
calls.set(calls.get() + 1);
Err(Error::HttpStatus {
status: 503,
url: "u".into(),
})
},
|_e, b| backoffs.borrow_mut().push(b),
);
assert!(matches!(res, Err(Error::HttpStatus { .. })));
assert_eq!(calls.get(), 7);
assert_eq!(*backoffs.borrow(), vec![100, 200, 400, 800, 1600, 3200]);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn retry_async_exhausts_budget_then_returns_last_error() {
use crate::backends::retry_async;
use crate::errors::Error;
use std::cell::{Cell, RefCell};
let calls = Cell::new(0u32);
let backoffs = RefCell::new(Vec::<u64>::new());
let res: crate::errors::Result<i32> = retry_async(
2,
|| {
calls.set(calls.get() + 1);
async {
Err(Error::HttpStatus {
status: 503,
url: "u".into(),
})
}
},
|_e, b| backoffs.borrow_mut().push(b),
|_b| async {},
)
.await;
assert!(matches!(res, Err(Error::HttpStatus { .. })));
assert_eq!(calls.get(), 3);
assert_eq!(*backoffs.borrow(), vec![100, 200]);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn retry_async_returns_ok_when_a_later_attempt_succeeds() {
use crate::backends::retry_async;
use crate::errors::Error;
use std::cell::{Cell, RefCell};
let calls = Cell::new(0u32);
let backoffs = RefCell::new(Vec::<u64>::new());
let res: crate::errors::Result<i32> = retry_async(
5,
|| {
calls.set(calls.get() + 1);
let done = calls.get() >= 3;
async move {
if done {
Ok(42)
} else {
Err(Error::HttpStatus {
status: 503,
url: "u".into(),
})
}
}
},
|_e, b| backoffs.borrow_mut().push(b),
|_b| async {},
)
.await;
assert_eq!(res.unwrap(), 42);
assert_eq!(calls.get(), 3);
assert_eq!(*backoffs.borrow(), vec![100, 200]);
}
#[test]
fn first_page_url_appends_per_page_only_when_no_query() {
use crate::backends::first_page_url;
assert_eq!(
first_page_url("https://api.github.com/repos/o/r/releases"),
"https://api.github.com/repos/o/r/releases?per_page=100"
);
assert_eq!(
first_page_url("https://api.github.com/repos/o/r/releases?page=2&per_page=20"),
"https://api.github.com/repos/o/r/releases?page=2&per_page=20"
);
}
#[test]
fn next_link_extracts_rel_next_from_link_header() {
use crate::backends::next_link;
use crate::http_client::header::{HeaderMap, LINK};
assert_eq!(next_link(&HeaderMap::new()), None);
let mut headers = HeaderMap::new();
headers.insert(
LINK,
"<https://api.example.com/r?page=2>; rel=\"next\""
.parse()
.unwrap(),
);
assert_eq!(
next_link(&headers),
Some("https://api.example.com/r?page=2".to_string())
);
let mut headers = HeaderMap::new();
headers.insert(
LINK,
"<https://api.example.com/r?page=5>; rel=\"last\", <https://api.example.com/r?page=2>; rel=\"next\""
.parse()
.unwrap(),
);
assert_eq!(
next_link(&headers),
Some("https://api.example.com/r?page=2".to_string())
);
}
}