//! Base HTTP client for makima API. use reqwest::Client; use serde::{de::DeserializeOwned, Serialize}; use std::time::Duration; use thiserror::Error; /// API client errors. #[derive(Error, Debug)] pub enum ApiError { #[error("HTTP request failed: {0}")] Request(#[from] reqwest::Error), #[error("API error (HTTP {status}): {message}")] Api { status: u16, message: String }, #[error("Failed to parse response: {0}")] Parse(String), } /// Maximum number of retry attempts for failed requests. const MAX_RETRIES: u32 = 3; /// Initial backoff delay in milliseconds. const INITIAL_BACKOFF_MS: u64 = 100; /// HTTP client for makima API. pub struct ApiClient { client: Client, base_url: String, api_key: String, } impl ApiClient { /// Create a new API client. pub fn new(base_url: String, api_key: String) -> Result { let client = Client::builder() .build()?; Ok(Self { client, base_url: base_url.trim_end_matches('/').to_string(), api_key, }) } /// Make a GET request with retry. pub async fn get(&self, path: &str) -> Result { let url = format!("{}{}", self.base_url, path); let mut last_error = None; for attempt in 0..MAX_RETRIES { if attempt > 0 { tokio::time::sleep(Self::backoff_delay(attempt - 1)).await; } let result = self.client .get(&url) // Send both headers - server will try tool key first, then API key .header("X-Makima-Tool-Key", &self.api_key) .header("X-Makima-API-Key", &self.api_key) .send() .await; match result { Ok(response) => { match self.handle_response(response).await { Ok(value) => return Ok(value), Err(e) if Self::is_retryable(&e) && attempt < MAX_RETRIES - 1 => { last_error = Some(e); continue; } Err(e) => return Err(e), } } Err(e) => { let error = ApiError::Request(e); if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 { last_error = Some(error); continue; } return Err(error); } } } Err(last_error.unwrap()) } /// Make a POST request with JSON body and retry. pub async fn post( &self, path: &str, body: &B, ) -> Result { let url = format!("{}{}", self.base_url, path); let mut last_error = None; for attempt in 0..MAX_RETRIES { if attempt > 0 { tokio::time::sleep(Self::backoff_delay(attempt - 1)).await; } let result = self.client .post(&url) // Send both headers - server will try tool key first, then API key .header("X-Makima-Tool-Key", &self.api_key) .header("X-Makima-API-Key", &self.api_key) .header("Content-Type", "application/json") .json(body) .send() .await; match result { Ok(response) => { match self.handle_response(response).await { Ok(value) => return Ok(value), Err(e) if Self::is_retryable(&e) && attempt < MAX_RETRIES - 1 => { last_error = Some(e); continue; } Err(e) => return Err(e), } } Err(e) => { let error = ApiError::Request(e); if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 { last_error = Some(error); continue; } return Err(error); } } } Err(last_error.unwrap()) } /// Make a POST request without body and retry. pub async fn post_empty(&self, path: &str) -> Result { let url = format!("{}{}", self.base_url, path); let mut last_error = None; for attempt in 0..MAX_RETRIES { if attempt > 0 { tokio::time::sleep(Self::backoff_delay(attempt - 1)).await; } let result = self.client .post(&url) // Send both headers - server will try tool key first, then API key .header("X-Makima-Tool-Key", &self.api_key) .header("X-Makima-API-Key", &self.api_key) .send() .await; match result { Ok(response) => { match self.handle_response(response).await { Ok(value) => return Ok(value), Err(e) if Self::is_retryable(&e) && attempt < MAX_RETRIES - 1 => { last_error = Some(e); continue; } Err(e) => return Err(e), } } Err(e) => { let error = ApiError::Request(e); if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 { last_error = Some(error); continue; } return Err(error); } } } Err(last_error.unwrap()) } /// Make a PUT request with JSON body and retry. pub async fn put( &self, path: &str, body: &B, ) -> Result { let url = format!("{}{}", self.base_url, path); let mut last_error = None; for attempt in 0..MAX_RETRIES { if attempt > 0 { tokio::time::sleep(Self::backoff_delay(attempt - 1)).await; } let result = self.client .put(&url) // Send both headers - server will try tool key first, then API key .header("X-Makima-Tool-Key", &self.api_key) .header("X-Makima-API-Key", &self.api_key) .header("Content-Type", "application/json") .json(body) .send() .await; match result { Ok(response) => { match self.handle_response(response).await { Ok(value) => return Ok(value), Err(e) if Self::is_retryable(&e) && attempt < MAX_RETRIES - 1 => { last_error = Some(e); continue; } Err(e) => return Err(e), } } Err(e) => { let error = ApiError::Request(e); if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 { last_error = Some(error); continue; } return Err(error); } } } Err(last_error.unwrap()) } /// Make a DELETE request with retry. pub async fn delete(&self, path: &str) -> Result<(), ApiError> { let url = format!("{}{}", self.base_url, path); let mut last_error = None; for attempt in 0..MAX_RETRIES { if attempt > 0 { tokio::time::sleep(Self::backoff_delay(attempt - 1)).await; } let result = self.client .delete(&url) .header("X-Makima-Tool-Key", &self.api_key) .header("X-Makima-API-Key", &self.api_key) .send() .await; match result { Ok(response) => { let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); let error = ApiError::Api { status: status.as_u16(), message: body, }; if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 { last_error = Some(error); continue; } return Err(error); } return Ok(()); } Err(e) => { let error = ApiError::Request(e); if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 { last_error = Some(error); continue; } return Err(error); } } } Err(last_error.unwrap()) } /// Handle API response. async fn handle_response( &self, response: reqwest::Response, ) -> Result { let status = response.status(); let status_code = status.as_u16(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); return Err(ApiError::Api { status: status_code, message: body, }); } let body = response.text().await?; // Handle empty responses if body.is_empty() || body == "null" { // Try to parse empty/null as the target type serde_json::from_str::("null") .or_else(|_| serde_json::from_str::("{}")) .map_err(|e| ApiError::Parse(e.to_string())) } else { serde_json::from_str::(&body) .map_err(|e| ApiError::Parse(format!("{}: {}", e, body))) } } /// Check if an error is retryable (connection errors or 5xx server errors). fn is_retryable(error: &ApiError) -> bool { match error { ApiError::Request(e) => { // Retry on connection errors, timeouts, etc. e.is_connect() || e.is_timeout() || e.is_request() } ApiError::Api { status, .. } => { // Retry on 5xx server errors *status >= 500 } ApiError::Parse(_) => false, } } /// Calculate backoff delay for a given attempt (exponential backoff). fn backoff_delay(attempt: u32) -> Duration { Duration::from_millis(INITIAL_BACKOFF_MS * 2u64.pow(attempt)) } }