feat: shared IONOS HTTP client with retry/backoff
This commit is contained in:
+62
-1
@@ -1 +1,62 @@
|
||||
// implemented in a later task
|
||||
use crate::model::AppError;
|
||||
|
||||
pub struct IonosClient {
|
||||
base_url: String,
|
||||
token: String,
|
||||
http: reqwest::blocking::Client,
|
||||
}
|
||||
|
||||
fn expand_tilde(p: &str) -> String {
|
||||
if let Some(rest) = p.strip_prefix("~/") {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return format!("{}/{}", home.to_string_lossy(), rest);
|
||||
}
|
||||
}
|
||||
p.to_string()
|
||||
}
|
||||
|
||||
impl IonosClient {
|
||||
pub fn new(base_url: &str, token_path: &str) -> Result<Self, AppError> {
|
||||
let path = expand_tilde(token_path);
|
||||
let token = std::fs::read_to_string(&path)
|
||||
.map_err(|e| AppError::Config(format!("token {path}: {e}")))?
|
||||
.trim().to_string();
|
||||
if token.is_empty() {
|
||||
return Err(AppError::Config(format!("empty token at {path}")));
|
||||
}
|
||||
let http = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(|e| AppError::Ionos(format!("client: {e}")))?;
|
||||
Ok(Self { base_url: base_url.trim_end_matches('/').to_string(), token, http })
|
||||
}
|
||||
|
||||
/// POST with up to 3 retries on transport / 5xx, exponential backoff.
|
||||
pub fn post_json<T: serde::de::DeserializeOwned>(
|
||||
&self, path: &str, body: &serde_json::Value,
|
||||
) -> Result<T, AppError> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let mut last = String::new();
|
||||
for attempt in 0..3 {
|
||||
let r = self.http.post(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.json(body)
|
||||
.send();
|
||||
match r {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
return resp.json::<T>()
|
||||
.map_err(|e| AppError::Ionos(format!("decode: {e}")));
|
||||
}
|
||||
Ok(resp) => {
|
||||
last = format!("HTTP {}", resp.status());
|
||||
if !resp.status().is_server_error() {
|
||||
return Err(AppError::Ionos(last));
|
||||
}
|
||||
}
|
||||
Err(e) => last = format!("transport: {e}"),
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(300 * (1 << attempt)));
|
||||
}
|
||||
Err(AppError::Ionos(format!("giving up after retries: {last}")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
use alpha_id::ionos::IonosClient;
|
||||
use std::io::Write;
|
||||
|
||||
fn mock_server(body: &'static str) -> (String, std::thread::JoinHandle<()>) {
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}", server.server_addr());
|
||||
let h = std::thread::spawn(move || {
|
||||
if let Ok(req) = server.recv() {
|
||||
let resp = tiny_http::Response::from_string(body);
|
||||
req.respond(resp).ok();
|
||||
}
|
||||
});
|
||||
(url, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posts_json_and_parses_response() {
|
||||
let (url, h) = mock_server(r#"{"ok":true}"#);
|
||||
let mut tf = tempfile::NamedTempFile::new().unwrap();
|
||||
write!(tf, "test-token-123").unwrap();
|
||||
let c = IonosClient::new(&url, tf.path().to_str().unwrap()).unwrap();
|
||||
let v: serde_json::Value = c.post_json("/ping", &serde_json::json!({"x":1})).unwrap();
|
||||
assert_eq!(v["ok"], true);
|
||||
h.join().ok();
|
||||
}
|
||||
Reference in New Issue
Block a user