From ee0a9c23d131e4fd7cf0ad3e336c708a2aa6fcb9 Mon Sep 17 00:00:00 2001 From: Tan Date: Fri, 30 Jan 2026 05:33:34 -0500 Subject: [PATCH 1/2] feat: add Gmail API rate limiting with exponential backoff - Add RATE_LIMIT_ERROR constant to gmail.rs - Implement exponential backoff with jitter when hitting rate limits - Add backoff_until and backoff_logged to AppState - Add calculate_backoff() function with 1-5 minute range - Add rand dependency for jitter calculation --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/gmail.rs | 15 +++++++++--- src-tauri/src/main.rs | 54 ++++++++++++++++++++++++++++++++++++++++++ src-tauri/src/types.rs | 2 ++ 5 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 103f0ae..83f53a1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3151,6 +3151,7 @@ dependencies = [ "keyring", "lazy_static", "log", + "rand 0.8.5", "regex", "reqwest", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 76f7075..1258d8c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ env_logger = "0.11" dirs = "5.0" sha2 = "0.10" hex = "0.4" +rand = "0.8" [dev-dependencies] # Use same dependencies for tests diff --git a/src-tauri/src/gmail.rs b/src-tauri/src/gmail.rs index e857d40..08c5b3c 100644 --- a/src-tauri/src/gmail.rs +++ b/src-tauri/src/gmail.rs @@ -1,9 +1,11 @@ use crate::keychain::KeychainManager; use chrono::Utc; -use reqwest::Client; +use reqwest::{Client, StatusCode}; use serde::Deserialize; use sha2::{Digest, Sha256}; +pub const RATE_LIMIT_ERROR: &str = "RATE_LIMIT_ERROR"; + const GMAIL_SCOPES: &[&str] = &["https://www.googleapis.com/auth/gmail.readonly"]; const OAUTH_REDIRECT_URI: &str = "http://localhost:8234/callback"; @@ -238,13 +240,20 @@ impl GmailClient { let list_url = "https://gmail.googleapis.com/gmail/v1/users/me/messages?q=is%3Aunread%20newer_than:1d&maxResults=25".to_string(); - let list_resp: MessageListResponse = self + let response = self .http_client .get(&list_url) .header("Authorization", format!("Bearer {}", access_token)) .send() .await - .map_err(|e| format!("Gmail API request failed: {}", e))? + .map_err(|e| format!("Gmail API request failed: {}", e))?; + + // Check for rate limit error (HTTP 429) + if response.status() == StatusCode::TOO_MANY_REQUESTS { + return Err(RATE_LIMIT_ERROR.to_string()); + } + + let list_resp: MessageListResponse = response .json() .await .map_err(|e| format!("Failed to parse message list: {}", e))?; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6bcc45c..af90bc7 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -29,6 +29,8 @@ use types::{AppState, ClipboardConfig, CodeEntry, PrivacyPreferences}; const DEFAULT_POLL_INTERVAL_MS: u64 = 8000; const NOTIFICATION_COOLDOWN_MS: u64 = 3000; const DEFAULT_CLIPBOARD_TIMEOUT_SECONDS: u64 = 30; +const BASE_BACKOFF_MS: u64 = 60_000; // 1 minute base backoff +const MAX_BACKOFF_MS: u64 = 300_000; // 5 minutes max backoff fn get_poll_interval() -> u64 { std::env::var("OTPBAR_POLL_INTERVAL_MS") @@ -93,6 +95,8 @@ fn main() { timeout_seconds: clipboard_timeout, }), privacy_preferences: tokio::sync::Mutex::new(loaded_prefs), + backoff_until: tokio::sync::Mutex::new(None), + backoff_logged: tokio::sync::Mutex::new(false), }) .setup(|app| { setup_menubar(app)?; @@ -227,15 +231,35 @@ async fn start_polling(handle: &tauri::AppHandle) { let handle_clone = handle.clone(); tauri::async_runtime::spawn(async move { + let mut retry_count = 0u32; + loop { tokio::time::sleep(tokio::time::Duration::from_millis(poll_interval)).await; let state: State = handle_clone.state(); + + // Check if we're in backoff period + let now = chrono::Utc::now().timestamp_millis(); + let backoff_until = *state.backoff_until.lock().await; + if let Some(until) = backoff_until { + if now < until { + // Still in backoff period, skip this poll + continue; + } + // Backoff period expired, clear it + *state.backoff_until.lock().await = None; + *state.backoff_logged.lock().await = false; + log::info!("Rate limit backoff expired, resuming normal polling"); + } + let mut client_guard = state.gmail_client.lock().await; if let Some(client) = client_guard.as_mut() { match client.get_recent_unread().await { Ok(messages) => { + // Reset retry count on success + retry_count = 0; + for msg in messages { let text = format!("{} {} {}", msg.subject, msg.snippet, msg.body); if let Some(otp_code) = otp::extract_otp(&text) { @@ -315,6 +339,30 @@ async fn start_polling(handle: &tauri::AppHandle) { } } } + Err(e) if e == gmail::RATE_LIMIT_ERROR => { + // Rate limit error - implement exponential backoff with jitter + let backoff_ms = calculate_backoff(retry_count); + retry_count = retry_count.saturating_add(1); + + // Add jitter: +/- 25% of backoff time + let jitter_ms = (backoff_ms as f64 * 0.25 * rand::random::()) as i64 + - (backoff_ms as i64 / 4); + let backoff_until = now + backoff_ms as i64 + jitter_ms; + + *state.backoff_until.lock().await = Some(backoff_until); + + // Only log once per backoff period + let mut logged = state.backoff_logged.lock().await; + if !*logged { + let backoff_seconds = (backoff_until - now) / 1000; + log::warn!( + "Gmail API rate limit exceeded. Backing off for ~{} seconds. Retry count: {}", + backoff_seconds, + retry_count + ); + *logged = true; + } + } Err(e) => { log::error!("Gmail polling failed: {}", e); } @@ -324,6 +372,12 @@ async fn start_polling(handle: &tauri::AppHandle) { }); } +/// Calculate exponential backoff with a maximum cap +fn calculate_backoff(retry_count: u32) -> u64 { + let backoff = BASE_BACKOFF_MS * 2u64.pow(retry_count.min(6)); + backoff.min(MAX_BACKOFF_MS) +} + fn extract_sender_name(from: &str) -> String { let re = regex::Regex::new(r"^([^<@]+)").expect("Sender name regex should be valid"); if let Some(caps) = re.captures(from) { diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index ae4dda5..0604cdc 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -55,4 +55,6 @@ pub struct AppState { pub is_polling: tokio::sync::Mutex, pub clipboard_config: tokio::sync::Mutex, pub privacy_preferences: tokio::sync::Mutex, + pub backoff_until: tokio::sync::Mutex>, + pub backoff_logged: tokio::sync::Mutex, } From 5f1c33820619b6a71de29399fb79677ba7c70401 Mon Sep 17 00:00:00 2001 From: Tan Date: Fri, 30 Jan 2026 05:36:13 -0500 Subject: [PATCH 2/2] feat: improve accessibility with focus states and ARIA labels - Add focus-visible ring styles to all interactive elements - Add aria-label attributes to icon-only buttons - Make CodeCard keyboard accessible with tabIndex and onKeyDown - Add Firefox scrollbar styling (scrollbar-width, scrollbar-color) - Remove duplicate shadow-inner-glow CSS utility - Remove unused Shield import from PrivacyDashboard --- src/App.tsx | 14 +++++--- src/components/Auth.tsx | 1 + src/components/CodeCard.tsx | 10 ++++++ src/components/PrivacyDashboard.tsx | 47 ++++++-------------------- src/components/Settings.tsx | 52 +++++++++++++++++++++++++---- src/index.css | 13 +++++--- 6 files changed, 84 insertions(+), 53 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index fc77363..a982dc6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -111,7 +111,7 @@ function App() { -
-
-
- -
-

Privacy Dashboard

-
+

Privacy Dashboard

{/* Content */} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 54effaa..3daa172 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -63,13 +63,51 @@ export const Settings: React.FC<{ ); } + if (!preferences) { + return ( +
+
+ +

Settings

+
+
+
+
+
+ +
+
+
+

No Settings Available

+

Unable to load preferences.

+
+ +
+
+
+ ); + } + if (error) { return (
@@ -88,7 +126,7 @@ export const Settings: React.FC<{
@@ -144,14 +183,15 @@ export const Settings: React.FC<{