summaryrefslogblamecommitdiff
path: root/makima/src/daemon/api/client.rs
blob: dbf310152e78f729fa784a7c2919a1fa46da3529 (plain) (tree)
1
2
3
4
5



                                             
                        














                                                    





                                                         



















                                                                             
                                      

                                                                                     





































                                                                                          

     
                                                     





                                                         







































                                                                                          

     
                                                   

                                                                                            





































                                                                                          

     
                                                    





                                                        







































                                                                                          

     
                                         

                                                                    
                                  
 






























                                                                                    









































                                                                                                      






                                                                                

         
                                

     




























                                                                         



















                                                                                
 
//! 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<Self, ApiError> {
        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<T: DeserializeOwned>(&self, path: &str) -> Result<T, 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
                .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<T: DeserializeOwned, B: Serialize>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<T, 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
                .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<T: DeserializeOwned>(&self, path: &str) -> Result<T, 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
                .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<T: DeserializeOwned, B: Serialize>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<T, 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
                .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())
    }

    /// Make a DELETE request with response and retry.
    pub async fn delete_with_response<T: DeserializeOwned>(&self, path: &str) -> Result<T, 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) => {
                    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())
    }

    /// Handle API response.
    async fn handle_response<T: DeserializeOwned>(
        &self,
        response: reqwest::Response,
    ) -> Result<T, ApiError> {
        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::<T>("null")
                .or_else(|_| serde_json::from_str::<T>("{}"))
                .map_err(|e| ApiError::Parse(e.to_string()))
        } else {
            serde_json::from_str::<T>(&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))
    }
}