1use std::time::Duration;
17use tokio::time::{sleep, timeout};
18
19use crate::{DbError, Result};
20
21#[derive(Clone, Copy, Debug)]
27pub struct RetryConfig {
28 pub max_retries: u32,
30 pub base_delay_ms: u64,
32 pub max_delay_ms: u64,
34}
35
36impl RetryConfig {
37 #[must_use]
40 pub fn connection() -> Self {
41 Self {
42 max_retries: 3,
43 base_delay_ms: 100,
44 max_delay_ms: 5000,
45 }
46 }
47
48 #[must_use]
51 pub fn deadlock() -> Self {
52 Self {
53 max_retries: 3,
54 base_delay_ms: 5,
55 max_delay_ms: 50,
56 }
57 }
58}
59
60impl Default for RetryConfig {
61 fn default() -> Self {
63 Self::connection()
64 }
65}
66
67pub fn is_retryable_sea_orm_error(error: &sea_orm::DbErr) -> bool {
75 use sea_orm::DbErr;
76
77 match error {
78 DbErr::ConnectionAcquire(_) | DbErr::Conn(_) => true,
79 _ => crate::database_error_kind(error)
80 .is_some_and(crate::DatabaseErrorKind::is_transient_locking),
81 }
82}
83
84pub async fn with_sea_orm_retry<F, Fut, T>(
97 operation_name: &str,
98 config: RetryConfig,
99 mut operation: F,
100) -> std::result::Result<T, sea_orm::DbErr>
101where
102 F: FnMut() -> Fut,
103 Fut: std::future::Future<Output = std::result::Result<T, sea_orm::DbErr>>,
104{
105 let mut attempt = 0_u32;
106 loop {
107 match operation().await {
108 Ok(value) => return Ok(value),
109 Err(error) if attempt < config.max_retries && is_retryable_sea_orm_error(&error) => {
110 let delay = calculate_delay(&config, attempt);
111 tracing::warn!(
112 operation = operation_name,
113 attempt = attempt + 1,
114 max_attempts = config.max_retries + 1,
115 delay_ms = duration_millis_u64(delay),
116 error = %error,
117 "retrying SeaORM operation"
118 );
119 sleep(delay).await;
120 attempt += 1;
121 }
122 Err(error) => return Err(error),
123 }
124 }
125}
126
127pub async fn with_sea_orm_retry_timeout<F, Fut, T>(
137 operation_name: &str,
138 config: RetryConfig,
139 attempt_timeout: Duration,
140 mut operation: F,
141) -> std::result::Result<T, sea_orm::DbErr>
142where
143 F: FnMut() -> Fut,
144 Fut: std::future::Future<Output = std::result::Result<T, sea_orm::DbErr>>,
145{
146 let mut attempt = 0_u32;
147 loop {
148 match timeout(attempt_timeout, operation()).await {
149 Ok(Ok(value)) => return Ok(value),
150 Ok(Err(error))
151 if attempt < config.max_retries && is_retryable_sea_orm_error(&error) =>
152 {
153 let delay = calculate_delay(&config, attempt);
154 tracing::warn!(
155 operation = operation_name,
156 attempt = attempt + 1,
157 max_attempts = config.max_retries + 1,
158 delay_ms = duration_millis_u64(delay),
159 error = %error,
160 "retrying SeaORM operation"
161 );
162 sleep(delay).await;
163 attempt += 1;
164 }
165 Ok(Err(error)) => return Err(error),
166 Err(_) if attempt < config.max_retries => {
167 let delay = calculate_delay(&config, attempt);
168 tracing::warn!(
169 operation = operation_name,
170 attempt = attempt + 1,
171 max_attempts = config.max_retries + 1,
172 timeout_ms = duration_millis_u64(attempt_timeout),
173 delay_ms = duration_millis_u64(delay),
174 "SeaORM operation attempt timed out; retrying"
175 );
176 sleep(delay).await;
177 attempt += 1;
178 }
179 Err(_) => {
180 return Err(sea_orm::DbErr::Custom(format!(
181 "operation '{operation_name}' timed out after {}ms",
182 duration_millis_u64(attempt_timeout)
183 )));
184 }
185 }
186 }
187}
188
189pub async fn with_retry<F, Fut, T>(config: &RetryConfig, operation: F) -> Result<T>
195where
196 F: Fn() -> Fut,
197 Fut: std::future::Future<Output = Result<T>>,
198{
199 let mut last_err = None;
200 for attempt in 0..=config.max_retries {
201 match operation().await {
202 Ok(val) => return Ok(val),
203 Err(e) => {
204 if attempt == config.max_retries || !is_retryable(&e) {
205 return Err(e);
206 }
207 let delay = calculate_delay(config, attempt);
208 tracing::warn!(
209 attempt = attempt + 1,
210 max = config.max_retries,
211 delay_ms = duration_millis_u64(delay),
212 error = %e,
213 "retrying operation"
214 );
215 last_err = Some(e);
216 sleep(delay).await;
217 }
218 }
219 }
220 Err(last_err.unwrap_or(DbError::RetryExhausted))
221}
222
223fn is_retryable(err: &DbError) -> bool {
224 err.is_retryable()
225}
226
227fn calculate_delay(config: &RetryConfig, attempt: u32) -> Duration {
228 use aster_forge_utils::backoff::{cap_delay, exponential_delay, randomized_jitter};
229
230 let raw = exponential_delay(Duration::from_millis(config.base_delay_ms), attempt);
231 cap_delay(
232 randomized_jitter(raw, 50, 150),
233 Duration::from_millis(config.max_delay_ms),
234 )
235}
236
237fn duration_millis_u64(duration: Duration) -> u64 {
238 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use std::sync::{
245 Arc,
246 atomic::{AtomicUsize, Ordering},
247 };
248
249 fn zero_delay_config(max_retries: u32) -> RetryConfig {
250 RetryConfig {
251 max_retries,
252 base_delay_ms: 0,
253 max_delay_ms: 0,
254 }
255 }
256
257 #[test]
258 fn retryable_error_classification_only_allows_database_errors() {
259 assert!(is_retryable(&DbError::database_connection(
260 "connection lost"
261 )));
262 assert!(is_retryable(&DbError::database_operation_classified(
263 "deadlock",
264 crate::DatabaseErrorKind::Deadlock
265 )));
266 assert!(!is_retryable(&DbError::database_operation("deadlock")));
268 assert!(!is_retryable(&DbError::non_retryable("invalid input")));
269 assert!(!is_retryable(&DbError::non_retryable("forbidden")));
270 }
271
272 #[test]
273 fn sea_orm_retryability_allows_connection_failures_before_any_statement() {
274 let conn_error = sea_orm::DbErr::Conn(sea_orm::error::RuntimeErr::Internal(
275 "connection reset".to_string(),
276 ));
277
278 assert!(is_retryable_sea_orm_error(&conn_error));
279 }
280
281 #[test]
282 fn sea_orm_retryability_rejects_non_driver_errors_without_reading_messages() {
283 assert!(!is_retryable_sea_orm_error(&sea_orm::DbErr::Custom(
286 "deadlock detected".to_string()
287 )));
288 assert!(!is_retryable_sea_orm_error(
289 &sea_orm::DbErr::RecordNotFound("deadlock".to_string())
290 ));
291 }
292
293 #[tokio::test]
294 async fn sea_orm_retryability_classifies_real_sqlite_busy_as_retryable() {
295 use aster_forge_test::temp::SqliteTestDatabase;
296 use sea_orm::{ConnectOptions, ConnectionTrait, SqlxSqliteConnector};
297
298 let database = SqliteTestDatabase::new("retry-busy");
299 let locker = SqlxSqliteConnector::connect(ConnectOptions::new(database.url()))
300 .await
301 .unwrap();
302 let contender = SqlxSqliteConnector::connect(ConnectOptions::new(database.url()))
303 .await
304 .unwrap();
305 contender
306 .execute_unprepared("PRAGMA busy_timeout=0;")
307 .await
308 .unwrap();
309 locker
310 .execute_unprepared("CREATE TABLE items (id INTEGER PRIMARY KEY);")
311 .await
312 .unwrap();
313 locker.execute_unprepared("BEGIN IMMEDIATE;").await.unwrap();
314 locker
315 .execute_unprepared("INSERT INTO items (id) VALUES (1);")
316 .await
317 .unwrap();
318
319 let error = contender
320 .execute_unprepared("INSERT INTO items (id) VALUES (2);")
321 .await
322 .unwrap_err();
323
324 locker.execute_unprepared("ROLLBACK;").await.unwrap();
325 contender.close().await.unwrap();
326 locker.close().await.unwrap();
327
328 assert!(
329 is_retryable_sea_orm_error(&error),
330 "SQLITE_BUSY from a locked database should be retryable, got: {error}"
331 );
332 assert_eq!(
333 crate::database_error_kind(&error),
334 Some(crate::DatabaseErrorKind::LockTimeout)
335 );
336 }
337
338 #[tokio::test]
339 async fn sea_orm_retryability_rejects_sqlite_unique_violation() {
340 use sea_orm::{ConnectionTrait, Database};
341
342 let db = Database::connect("sqlite::memory:").await.unwrap();
343 db.execute_unprepared("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT UNIQUE);")
344 .await
345 .unwrap();
346 db.execute_unprepared("INSERT INTO items (name) VALUES ('a');")
347 .await
348 .unwrap();
349
350 let error = db
351 .execute_unprepared("INSERT INTO items (name) VALUES ('a');")
352 .await
353 .unwrap_err();
354
355 assert!(!is_retryable_sea_orm_error(&error));
356 assert_eq!(
357 crate::database_error_kind(&error),
358 Some(crate::DatabaseErrorKind::UniqueConstraint)
359 );
360 }
361
362 #[test]
363 fn calculate_delay_applies_jitter_and_hard_caps_max_delay() {
364 let config = RetryConfig {
365 max_retries: 3,
366 base_delay_ms: 100,
367 max_delay_ms: 250,
368 };
369
370 let expected_bounds = [(0, 50, 150), (1, 100, 250), (2, 200, 250), (8, 250, 250)];
371
372 for (attempt, min_ms, max_ms) in expected_bounds {
373 for _ in 0..64 {
374 let delay_ms = duration_millis_u64(calculate_delay(&config, attempt));
375 assert!(
376 (min_ms..=max_ms).contains(&delay_ms),
377 "attempt {attempt} produced {delay_ms}ms outside [{min_ms}, {max_ms}]"
378 );
379 }
380 }
381 }
382
383 #[test]
384 fn calculate_delay_handles_zero_and_initial_above_max_boundaries() {
385 let zero_base = RetryConfig {
386 max_retries: 1,
387 base_delay_ms: 0,
388 max_delay_ms: 100,
389 };
390 let zero_max = RetryConfig {
391 max_retries: 1,
392 base_delay_ms: 100,
393 max_delay_ms: 0,
394 };
395 let initial_above_max = RetryConfig {
396 max_retries: 1,
397 base_delay_ms: 1_000,
398 max_delay_ms: 250,
399 };
400
401 for attempt in [0, 1, u32::MAX] {
402 assert_eq!(calculate_delay(&zero_base, attempt), Duration::ZERO);
403 assert_eq!(calculate_delay(&zero_max, attempt), Duration::ZERO);
404 assert_eq!(
405 calculate_delay(&initial_above_max, attempt),
406 Duration::from_millis(250)
407 );
408 }
409 }
410
411 #[tokio::test]
412 async fn with_retry_retries_retryable_errors_until_success() {
413 let attempts = Arc::new(AtomicUsize::new(0));
414 let result = {
415 let attempts = Arc::clone(&attempts);
416 with_retry(&zero_delay_config(3), move || {
417 let attempts = Arc::clone(&attempts);
418 async move {
419 let attempt = attempts.fetch_add(1, Ordering::SeqCst);
420 if attempt < 2 {
421 Err(DbError::database_operation_classified(
422 "deadlock",
423 crate::DatabaseErrorKind::Deadlock,
424 ))
425 } else {
426 Ok("ok")
427 }
428 }
429 })
430 .await
431 };
432
433 assert_eq!(result.unwrap(), "ok");
434 assert_eq!(attempts.load(Ordering::SeqCst), 3);
435 }
436
437 #[tokio::test]
438 async fn with_retry_stops_immediately_for_non_retryable_errors() {
439 let attempts = Arc::new(AtomicUsize::new(0));
440 let result = {
441 let attempts = Arc::clone(&attempts);
442 with_retry(&zero_delay_config(3), move || {
443 let attempts = Arc::clone(&attempts);
444 async move {
445 attempts.fetch_add(1, Ordering::SeqCst);
446 Err::<(), _>(DbError::non_retryable("bad request"))
447 }
448 })
449 .await
450 };
451
452 assert!(matches!(result.unwrap_err(), DbError::NonRetryable(_)));
453 assert_eq!(attempts.load(Ordering::SeqCst), 1);
454 }
455
456 #[tokio::test]
457 async fn with_retry_stops_after_exhausting_retry_budget() {
458 let attempts = Arc::new(AtomicUsize::new(0));
459 let result = {
460 let attempts = Arc::clone(&attempts);
461 with_retry(&zero_delay_config(2), move || {
462 let attempts = Arc::clone(&attempts);
463 async move {
464 attempts.fetch_add(1, Ordering::SeqCst);
465 Err::<(), _>(DbError::database_connection("still failing"))
466 }
467 })
468 .await
469 };
470
471 assert!(matches!(
472 result.unwrap_err(),
473 DbError::DatabaseConnection(_)
474 ));
475 assert_eq!(attempts.load(Ordering::SeqCst), 3);
476 }
477
478 #[tokio::test]
479 async fn with_retry_zero_budget_runs_exactly_one_attempt() {
480 let attempts = Arc::new(AtomicUsize::new(0));
481 let result = {
482 let attempts = Arc::clone(&attempts);
483 with_retry(&zero_delay_config(0), move || {
484 let attempts = Arc::clone(&attempts);
485 async move {
486 attempts.fetch_add(1, Ordering::SeqCst);
487 Err::<(), _>(DbError::database_connection("still failing"))
488 }
489 })
490 .await
491 };
492
493 assert!(matches!(
494 result.unwrap_err(),
495 DbError::DatabaseConnection(_)
496 ));
497 assert_eq!(attempts.load(Ordering::SeqCst), 1);
498 }
499}