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}