feat: shared IONOS HTTP client with retry/backoff

This commit is contained in:
2026-05-18 18:37:31 +02:00
parent 41debc4f60
commit ff41d53155
2 changed files with 87 additions and 1 deletions
+62 -1
View File
@@ -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}")))
}
}
+25
View File
@@ -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();
}