absurder_sql/storage/retry_logic.rs
1//! Retry Logic for IndexedDB Operations with Exponential Backoff
2//!
3//! Provides enterprise-grade retry functionality for transient IndexedDB failures.
4//!
5//! ## Features
6//! - Exponential backoff (100ms, 200ms, 400ms)
7//! - Max 3 retry attempts
8//! - Quota exceeded errors are NOT retried (permanent failures)
9//! - Transient errors are retried (transaction failures, network issues)
10//! - Comprehensive logging for debugging
11
12use crate::types::DatabaseError;
13use std::future::Future;
14
15/// Maximum number of retry attempts for transient failures
16const MAX_RETRY_ATTEMPTS: u32 = 3;
17
18/// Base delay in milliseconds for exponential backoff
19const BASE_DELAY_MS: u32 = 100;
20
21/// Determine if an error is retriable
22///
23/// # Retriable Errors
24/// - TRANSACTION_ERROR - IndexedDB transaction failed
25/// - INDEXEDDB_ERROR - Generic IndexedDB error
26/// - NETWORK_ERROR - Network-related failure
27/// - STORE_ERROR - Object store access error
28/// - GET_ERROR, PUT_ERROR - Operation-specific errors
29///
30/// # Non-Retriable Errors
31/// - QuotaExceededError - Storage quota exceeded (needs user intervention)
32/// - INVALID_STATE_ERROR - Invalid state (programming error)
33/// - NOT_FOUND_ERROR - Resource not found (won't exist on retry)
34/// - CONSTRAINT_ERROR - Database constraint violation
35pub fn is_retriable_error(error: &DatabaseError) -> bool {
36 let code = error.code.as_str();
37
38 // Quota errors are never retriable
39 if code.contains("Quota") || code.contains("quota") {
40 log::debug!("Error is quota-related, not retriable: {}", code);
41 return false;
42 }
43
44 // Invalid state and not found errors are not retriable
45 if code.contains("INVALID_STATE") || code.contains("NOT_FOUND") || code.contains("CONSTRAINT") {
46 log::debug!("Error is permanent, not retriable: {}", code);
47 return false;
48 }
49
50 // Everything else is potentially retriable (transient failures)
51 log::debug!("Error is retriable: {}", code);
52 true
53}
54
55/// Execute an async operation with retry logic and exponential backoff
56///
57/// # Arguments
58/// * `operation_name` - Name of the operation for logging
59/// * `operation` - Async function to execute
60///
61/// # Returns
62/// * `Ok(T)` - Operation succeeded
63/// * `Err(DatabaseError)` - Operation failed after all retry attempts
64///
65/// # Example
66/// ```ignore
67/// let result = with_retry("persist_to_indexeddb", || async {
68/// persist_to_indexeddb_internal(db_name, blocks).await
69/// }).await?;
70/// ```
71pub async fn with_retry<F, Fut, T>(
72 operation_name: &str,
73 mut operation: F,
74) -> Result<T, DatabaseError>
75where
76 F: FnMut() -> Fut,
77 Fut: Future<Output = Result<T, DatabaseError>>,
78{
79 let mut attempt = 0;
80
81 loop {
82 attempt += 1;
83
84 log::debug!(
85 "Attempt {}/{} for operation: {}",
86 attempt,
87 MAX_RETRY_ATTEMPTS,
88 operation_name
89 );
90
91 match operation().await {
92 Ok(result) => {
93 if attempt > 1 {
94 log::info!(
95 "Operation '{}' succeeded after {} attempts",
96 operation_name,
97 attempt
98 );
99 }
100 return Ok(result);
101 }
102 Err(error) => {
103 log::warn!(
104 "Attempt {}/{} failed for '{}': {} - {}",
105 attempt,
106 MAX_RETRY_ATTEMPTS,
107 operation_name,
108 error.code,
109 error.message
110 );
111
112 // Check if error is retriable
113 if !is_retriable_error(&error) {
114 log::error!(
115 "Non-retriable error for '{}': {} - {}",
116 operation_name,
117 error.code,
118 error.message
119 );
120 return Err(error);
121 }
122
123 // Check if we've exhausted retry attempts
124 if attempt >= MAX_RETRY_ATTEMPTS {
125 log::error!(
126 "Max retry attempts ({}) exceeded for '{}': {} - {}",
127 MAX_RETRY_ATTEMPTS,
128 operation_name,
129 error.code,
130 error.message
131 );
132 return Err(DatabaseError::new(
133 "MAX_RETRIES_EXCEEDED",
134 &format!(
135 "Operation '{}' failed after {} attempts. Last error: {} - {}",
136 operation_name, MAX_RETRY_ATTEMPTS, error.code, error.message
137 ),
138 ));
139 }
140
141 // Calculate exponential backoff delay: 100ms, 200ms, 400ms
142 let delay_ms = BASE_DELAY_MS * 2_u32.pow(attempt - 1);
143 log::debug!(
144 "Retrying '{}' after {}ms delay (attempt {}/{})",
145 operation_name,
146 delay_ms,
147 attempt,
148 MAX_RETRY_ATTEMPTS
149 );
150
151 // Wait before retrying
152 #[cfg(target_arch = "wasm32")]
153 {
154 // Use setTimeout to yield to browser event loop
155 let promise = js_sys::Promise::new(&mut |resolve, _reject| {
156 web_sys::window()
157 .unwrap()
158 .set_timeout_with_callback_and_timeout_and_arguments_0(
159 &resolve,
160 delay_ms as i32,
161 )
162 .unwrap();
163 });
164 wasm_bindgen_futures::JsFuture::from(promise).await.ok();
165 }
166
167 #[cfg(not(target_arch = "wasm32"))]
168 {
169 tokio::time::sleep(std::time::Duration::from_millis(delay_ms as u64)).await;
170 }
171 }
172 }
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn test_is_retriable_quota_error() {
182 let error = DatabaseError::new("QuotaExceededError", "Storage quota exceeded");
183 assert!(
184 !is_retriable_error(&error),
185 "Quota error should not be retriable"
186 );
187 }
188
189 #[test]
190 fn test_is_retriable_transaction_error() {
191 let error = DatabaseError::new("TRANSACTION_ERROR", "Transaction failed");
192 assert!(
193 is_retriable_error(&error),
194 "Transaction error should be retriable"
195 );
196 }
197
198 #[test]
199 fn test_is_retriable_invalid_state() {
200 let error = DatabaseError::new("INVALID_STATE_ERROR", "Invalid state");
201 assert!(
202 !is_retriable_error(&error),
203 "Invalid state should not be retriable"
204 );
205 }
206
207 #[test]
208 fn test_is_retriable_not_found() {
209 let error = DatabaseError::new("NOT_FOUND_ERROR", "Not found");
210 assert!(
211 !is_retriable_error(&error),
212 "Not found should not be retriable"
213 );
214 }
215
216 #[test]
217 fn test_is_retriable_indexeddb_error() {
218 let error = DatabaseError::new("INDEXEDDB_ERROR", "IndexedDB error");
219 assert!(
220 is_retriable_error(&error),
221 "IndexedDB error should be retriable"
222 );
223 }
224}