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