summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-24 18:11:41 +0000
committersoryu <soryu@soryu.co>2026-01-24 18:11:41 +0000
commit792d12df6b1b1bc4f327cbe8e71e7986c67e98f6 (patch)
tree5fb72a12a6f1903a6294d42d933c0be5f5e50095
parent4ea35373c08ca7c212dbc7739901168ee4b30753 (diff)
downloadsoryu-792d12df6b1b1bc4f327cbe8e71e7986c67e98f6.tar.gz
soryu-792d12df6b1b1bc4f327cbe8e71e7986c67e98f6.zip
Fix history and add retries to makima CLI
-rw-r--r--makima/migrations/20250124000000_fix_history_events_owner_fk.sql12
-rw-r--r--makima/src/daemon/api/client.rs287
2 files changed, 240 insertions, 59 deletions
diff --git a/makima/migrations/20250124000000_fix_history_events_owner_fk.sql b/makima/migrations/20250124000000_fix_history_events_owner_fk.sql
new file mode 100644
index 0000000..1f97779
--- /dev/null
+++ b/makima/migrations/20250124000000_fix_history_events_owner_fk.sql
@@ -0,0 +1,12 @@
+-- Fix history_events owner_id foreign key
+-- The owner_id should reference owners(id), not users(id)
+-- This was causing all history event inserts to fail silently
+
+-- Drop the incorrect foreign key constraint
+ALTER TABLE history_events
+ DROP CONSTRAINT IF EXISTS history_events_owner_id_fkey;
+
+-- Add the correct foreign key constraint referencing owners
+ALTER TABLE history_events
+ ADD CONSTRAINT history_events_owner_id_fkey
+ FOREIGN KEY (owner_id) REFERENCES owners(id) ON DELETE CASCADE;
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))
+ }
}