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