diff options
Diffstat (limited to 'makima/src/daemon/api/client.rs')
| -rw-r--r-- | makima/src/daemon/api/client.rs | 287 |
1 files changed, 228 insertions, 59 deletions
diff --git a/makima/src/daemon/api/client.rs b/makima/src/daemon/api/client.rs index ca1b2a8..4ba4778 100644 --- a/makima/src/daemon/api/client.rs +++ b/makima/src/daemon/api/client.rs @@ -2,6 +2,7 @@ use reqwest::Client; use serde::{de::DeserializeOwned, Serialize}; +use std::time::Duration; use thiserror::Error; /// API client errors. @@ -17,6 +18,12 @@ pub enum ApiError { 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, @@ -37,94 +44,236 @@ impl ApiClient { }) } - /// Make a GET request. + /// Make a GET request with retry. pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> { let url = format!("{}{}", self.base_url, path); - let response = 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?; - - self.handle_response(response).await + 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. + /// Make a POST request with JSON body and retry. pub async fn post<T: DeserializeOwned, B: Serialize>( &self, path: &str, body: &B, ) -> Result<T, ApiError> { let url = format!("{}{}", self.base_url, path); - let response = 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?; - - self.handle_response(response).await + 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. + /// Make a POST request without body and retry. pub async fn post_empty<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> { let url = format!("{}{}", self.base_url, path); - let response = 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?; - - self.handle_response(response).await + 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. + /// Make a PUT request with JSON body and retry. pub async fn put<T: DeserializeOwned, B: Serialize>( &self, path: &str, body: &B, ) -> Result<T, ApiError> { let url = format!("{}{}", self.base_url, path); - let response = 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?; - - self.handle_response(response).await + 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. + /// Make a DELETE request with retry. pub async fn delete(&self, path: &str) -> Result<(), ApiError> { let url = format!("{}{}", self.base_url, path); - let response = self.client - .delete(&url) - .header("X-Makima-Tool-Key", &self.api_key) - .header("X-Makima-API-Key", &self.api_key) - .send() - .await?; + let mut last_error = None; - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(ApiError::Api { - status: status.as_u16(), - message: body, - }); + 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); + } + } } - Ok(()) + Err(last_error.unwrap()) } /// Handle API response. @@ -156,4 +305,24 @@ impl ApiClient { .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)) + } } |
