diff --git a/krillnotes-core/src/core/error.rs b/krillnotes-core/src/core/error.rs index e4680357..54881057 100644 --- a/krillnotes-core/src/core/error.rs +++ b/krillnotes-core/src/core/error.rs @@ -138,8 +138,11 @@ pub enum KrillnotesError { RelayAuthExpired(String), /// Relay rate limit exceeded (HTTP 429). - #[error("Relay rate limited: {0}")] - RelayRateLimited(String), + #[error("Relay rate limited: {message}")] + RelayRateLimited { + message: String, + retry_after_secs: Option, + }, /// Relay resource not found or expired (HTTP 404/410). #[error("Relay not found: {0}")] @@ -240,7 +243,13 @@ impl KrillnotesError { Self::Zip(e) => format!("Bundle archive error: {e}"), Self::NotOwner => "Only the workspace owner can modify scripts".to_string(), Self::RelayAuthExpired(_) => "Relay session expired. Please log in again.".to_string(), - Self::RelayRateLimited(_) => "Relay is rate limiting requests. Please try again later.".to_string(), + Self::RelayRateLimited { retry_after_secs, .. } => { + if let Some(secs) = retry_after_secs { + format!("Relay is rate limiting requests. Please try again in {secs} seconds.") + } else { + "Relay is rate limiting requests. Please try again later.".to_string() + } + } Self::RelayNotFound(_) => "The requested relay resource was not found or has expired.".to_string(), Self::RelayUnavailable(msg) => format!("Relay server unavailable: {msg}"), Self::Permission(e) => format!("Permission denied: {e}"), diff --git a/krillnotes-core/src/core/swarm/sync.rs b/krillnotes-core/src/core/swarm/sync.rs index f1717d24..f08a066c 100644 --- a/krillnotes-core/src/core/swarm/sync.rs +++ b/krillnotes-core/src/core/swarm/sync.rs @@ -123,8 +123,8 @@ pub fn generate_delta( attachment_sizes.insert(attachment_id.clone(), size); } Err(e) => { - log::warn!(target: "krillnotes::sync", - "Could not query size for attachment {}, will skip blob: {e}", + log::debug!(target: "krillnotes::sync", + "attachment {} blob unavailable (note likely deleted), skipping sidecar: {e}", attachment_id); } } diff --git a/krillnotes-core/src/core/sync/relay/client.rs b/krillnotes-core/src/core/sync/relay/client.rs index 5325e821..91371f09 100644 --- a/krillnotes-core/src/core/sync/relay/client.rs +++ b/krillnotes-core/src/core/sync/relay/client.rs @@ -260,6 +260,12 @@ impl RelayClient { fn map_error(resp: reqwest::blocking::Response) -> KrillnotesError { let status = resp.status().as_u16(); + // Parse Retry-After header before consuming the response body. + let retry_after_secs = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()); let body = resp.text().unwrap_or_default(); // Extract human-readable message from {"error":{"message":"..."}} envelope. let message = Self::extract_error_message(&body).unwrap_or_else(|| { @@ -273,7 +279,10 @@ impl RelayClient { 401 => KrillnotesError::RelayAuthExpired(message), 404 | 410 => KrillnotesError::RelayNotFound(message), 409 => KrillnotesError::RelayUnavailable(format!("HTTP 409: {message}")), - 429 => KrillnotesError::RelayRateLimited(message), + 429 => KrillnotesError::RelayRateLimited { + message, + retry_after_secs, + }, _ => KrillnotesError::RelayUnavailable(format!("HTTP {status}: {message}")), } } diff --git a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs index 0424c619..e06d836f 100644 --- a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs +++ b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs @@ -6,6 +6,7 @@ use crate::AppState; use krillnotes_core::core::sync::relay::RelayClient; +use krillnotes_core::KrillnotesError; use krillnotes_core::core::sync::SyncChannel; use serde::Serialize; use std::sync::Arc; @@ -332,8 +333,15 @@ pub async fn poll_receive_workspace( // List all pending relay bundles (single API call). let all_bundles = match client.list_bundles(&device_id) { Ok(b) => b, + Err(ref e @ KrillnotesError::RelayRateLimited { retry_after_secs, .. }) => { + log::debug!( + "poll_receive_workspace: list_bundles rate-limited, retry_after={:?}: {e}", + retry_after_secs + ); + vec![] + } Err(e) => { - log::warn!("poll_receive_workspace: list_bundles failed: {e}"); + log::debug!("poll_receive_workspace: list_bundles skipped: {e}"); vec![] } }; @@ -863,7 +871,7 @@ pub async fn poll_all_identity_snapshots( }; for uuid in identity_uuids { - // 1. Check relay accounts FIRST (needed for both discovery and polling). + // 1. Check relay accounts (needed for both discovery and invite polling). let relay_accounts = { let rams = state .relay_account_managers @@ -882,7 +890,6 @@ pub async fn poll_all_identity_snapshots( // "Send to My Device" puts a snapshot bundle on the relay but the receiving // device has no AcceptedInvite for it, so normal polling would never find it. { - // Collect workspace_ids from ALL existing invites (any status). let all_invite_ws_ids: std::collections::HashSet = { let aim = state .accepted_invite_managers @@ -940,7 +947,7 @@ pub async fn poll_all_identity_snapshots( } } Err(e) => { - log::warn!("list_bundles for self-transfer discovery failed: {e}") + log::debug!("list_bundles for self-transfer discovery: {e}") } } } @@ -949,7 +956,6 @@ pub async fn poll_all_identity_snapshots( .await .map_err(|e| e.to_string())?; - // Create synthetic accepted invites for discovered self-transfers. for (meta, bytes, relay_url) in self_transfers { let snapshot_path = std::env::temp_dir().join(format!("snapshot-{}.bin", Uuid::new_v4())); @@ -1046,7 +1052,6 @@ pub async fn poll_all_identity_snapshots( .await .map_err(|e| e.to_string())??; - // Update accepted invites with snapshot paths and emit events. for snapshot in &result.received_snapshots { { let mut aim = state