//! Comprehensive tests for S3 retry logic and error handling //! //! Tests cover: //! - Upload retry scenarios (transient failures) //! - Download failure recovery //! - Network timeout handling //! - Connection failure scenarios //! - Metadata operation failures //! - List operation failures //! - Retry backoff behavior //! - Error categorization use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; use bytes::Bytes; use futures::stream; use object_store::memory::InMemory; use object_store::path::Path; use object_store::{ Error as ObjectStoreError, GetOptions, GetResult, GetResultPayload, ListResult, ObjectMeta, ObjectStore, PutOptions, PutResult, }; use storage::model_helpers::RetryConfig; use storage::object_store_backend::ObjectStoreBackend; use storage::Storage; use tokio::sync::Mutex; /// Mock ObjectStore that simulates transient failures #[derive(Debug)] struct FailingObjectStore { inner: Arc, /// Number of failures before success failures_before_success: Arc>, /// Count of attempts made attempt_count: Arc, /// Type of error to simulate error_type: Arc>, } impl std::fmt::Display for FailingObjectStore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "FailingObjectStore") } } #[derive(Debug, Clone, Copy, PartialEq)] enum ErrorType { NotFound, Generic, AlreadyExists, Precondition, NotModified, NotImplemented, Unauthenticated, UnknownConfigurationKey, } impl FailingObjectStore { fn new(failures: usize, error_type: ErrorType) -> Self { Self { inner: Arc::new(InMemory::new()), failures_before_success: Arc::new(Mutex::new(failures)), attempt_count: Arc::new(AtomicUsize::new(0)), error_type: Arc::new(Mutex::new(error_type)), } } async fn should_fail(&self) -> bool { let mut failures = self.failures_before_success.lock().await; if *failures > 0 { *failures -= 1; true } else { false } } fn get_attempt_count(&self) -> usize { self.attempt_count.load(Ordering::SeqCst) } async fn get_error(&self) -> ObjectStoreError { let error_type = *self.error_type.lock().await; match error_type { ErrorType::NotFound => ObjectStoreError::NotFound { path: "test/path".to_string(), source: Box::new(std::io::Error::new( std::io::ErrorKind::NotFound, "not found", )), }, ErrorType::Generic => ObjectStoreError::Generic { store: "test", source: Box::new(std::io::Error::new( std::io::ErrorKind::Other, "generic error", )), }, ErrorType::AlreadyExists => ObjectStoreError::AlreadyExists { path: "test/path".to_string(), source: Box::new(std::io::Error::new( std::io::ErrorKind::AlreadyExists, "already exists", )), }, ErrorType::Precondition => ObjectStoreError::Precondition { path: "test/path".to_string(), source: Box::new(std::io::Error::new( std::io::ErrorKind::Other, "precondition failed", )), }, ErrorType::NotModified => ObjectStoreError::NotModified { path: "test/path".to_string(), source: Box::new(std::io::Error::new( std::io::ErrorKind::Other, "not modified", )), }, ErrorType::NotImplemented => ObjectStoreError::NotImplemented, ErrorType::Unauthenticated => ObjectStoreError::Unauthenticated { path: "test/path".to_string(), source: Box::new(std::io::Error::new( std::io::ErrorKind::PermissionDenied, "unauthenticated", )), }, ErrorType::UnknownConfigurationKey => ObjectStoreError::UnknownConfigurationKey { store: "test", key: "unknown_key".to_string(), }, } } } #[async_trait::async_trait] impl ObjectStore for FailingObjectStore { async fn put(&self, location: &Path, payload: PutPayload) -> object_store::Result { self.attempt_count.fetch_add(1, Ordering::SeqCst); if self.should_fail().await { Err(self.get_error().await) } else { self.inner.put(location, payload).await } } async fn put_opts( &self, location: &Path, payload: PutPayload, opts: PutOptions, ) -> object_store::Result { self.attempt_count.fetch_add(1, Ordering::SeqCst); if self.should_fail().await { Err(self.get_error().await) } else { self.inner.put_opts(location, payload, opts).await } } async fn get(&self, location: &Path) -> object_store::Result { self.attempt_count.fetch_add(1, Ordering::SeqCst); if self.should_fail().await { Err(self.get_error().await) } else { self.inner.get(location).await } } async fn get_opts( &self, location: &Path, options: GetOptions, ) -> object_store::Result { self.attempt_count.fetch_add(1, Ordering::SeqCst); if self.should_fail().await { Err(self.get_error().await) } else { self.inner.get_opts(location, options).await } } async fn get_range(&self, location: &Path, range: std::ops::Range) -> object_store::Result { self.attempt_count.fetch_add(1, Ordering::SeqCst); if self.should_fail().await { Err(self.get_error().await) } else { self.inner.get_range(location, range).await } } async fn head(&self, location: &Path) -> object_store::Result { self.attempt_count.fetch_add(1, Ordering::SeqCst); if self.should_fail().await { Err(self.get_error().await) } else { self.inner.head(location).await } } async fn delete(&self, location: &Path) -> object_store::Result<()> { self.attempt_count.fetch_add(1, Ordering::SeqCst); if self.should_fail().await { Err(self.get_error().await) } else { self.inner.delete(location).await } } fn list(&self, prefix: Option<&Path>) -> futures::stream::BoxStream<'_, object_store::Result> { // For simplicity, list doesn't fail in this mock self.inner.list(prefix) } async fn list_with_delimiter(&self, prefix: Option<&Path>) -> object_store::Result { self.attempt_count.fetch_add(1, Ordering::SeqCst); if self.should_fail().await { Err(self.get_error().await) } else { self.inner.list_with_delimiter(prefix).await } } async fn copy(&self, from: &Path, to: &Path) -> object_store::Result<()> { self.attempt_count.fetch_add(1, Ordering::SeqCst); if self.should_fail().await { Err(self.get_error().await) } else { self.inner.copy(from, to).await } } async fn copy_if_not_exists(&self, from: &Path, to: &Path) -> object_store::Result<()> { self.attempt_count.fetch_add(1, Ordering::SeqCst); if self.should_fail().await { Err(self.get_error().await) } else { self.inner.copy_if_not_exists(from, to).await } } async fn put_multipart_opts( &self, location: &Path, opts: object_store::PutMultipartOpts, ) -> object_store::Result> { self.attempt_count.fetch_add(1, Ordering::SeqCst); if self.should_fail().await { Err(self.get_error().await) } else { self.inner.put_multipart_opts(location, opts).await } } } use object_store::PutPayload; // Test 1: Upload retry with transient failures #[tokio::test] async fn test_upload_retry_transient_failures() { // Configure to fail twice, then succeed let store = Arc::new(FailingObjectStore::new(2, ErrorType::Generic)); let backend = storage::object_store_backend::test_helpers::new_for_testing(store.clone() as Arc, "test-bucket".to_string()) .with_retry_config(RetryConfig { max_attempts: 3, initial_delay: Duration::from_millis(10), max_delay: Duration::from_secs(1), backoff_multiplier: 2.0, }); let test_data = b"test data for retry"; let result = backend.store("test/retry.txt", test_data).await; // Should succeed on third attempt assert!(result.is_ok(), "Upload should succeed after retries"); assert_eq!( store.get_attempt_count(), 3, "Should have made exactly 3 attempts" ); } // Test 2: Upload failure after exhausting retries #[tokio::test] async fn test_upload_failure_max_retries_exceeded() { // Configure to fail 5 times (more than max retries) let store = Arc::new(FailingObjectStore::new(5, ErrorType::Generic)); let backend = storage::object_store_backend::test_helpers::new_for_testing(store.clone() as Arc, "test-bucket".to_string()) .with_retry_config(RetryConfig { max_attempts: 3, initial_delay: Duration::from_millis(10), max_delay: Duration::from_secs(1), backoff_multiplier: 2.0, }); let test_data = b"test data that will fail"; let result = backend.store("test/fail.txt", test_data).await; // Should fail after max retries assert!(result.is_err(), "Upload should fail after exhausting retries"); assert_eq!( store.get_attempt_count(), 3, "Should have made exactly max_attempts" ); } // Test 3: Download retry with transient failures #[tokio::test] async fn test_download_retry_transient_failures() { // First store data successfully let inner_store = Arc::new(InMemory::new()); let test_data = b"test data for download"; inner_store .put(&Path::from("test/download.txt"), Bytes::from_static(test_data).into()) .await .unwrap(); // Create failing store that wraps the inner store let store = Arc::new(FailingObjectStore::new(2, ErrorType::Generic)); // Manually store the data in the failing store's inner store store.inner .put(&Path::from("test/download.txt"), Bytes::from_static(test_data).into()) .await .unwrap(); let backend = storage::object_store_backend::test_helpers::new_for_testing(store.clone() as Arc, "test-bucket".to_string()) .with_retry_config(RetryConfig { max_attempts: 3, initial_delay: Duration::from_millis(10), max_delay: Duration::from_secs(1), backoff_multiplier: 2.0, }); // Download should succeed after retries (but retrieve doesn't use with_retry) let result = backend.retrieve("test/download.txt").await; // Note: retrieve() doesn't use with_retry in current implementation // So this will fail on first attempt assert!(result.is_err(), "Retrieve doesn't use retry logic currently"); } // Test 4: Metadata operation with retry #[tokio::test] async fn test_metadata_retry_transient_failures() { // First store data successfully let store = Arc::new(FailingObjectStore::new(0, ErrorType::Generic)); let test_data = b"test data"; store.inner .put(&Path::from("test/metadata.txt"), Bytes::from_static(test_data).into()) .await .unwrap(); // Now configure to fail on head operations *store.failures_before_success.lock().await = 2; let backend = storage::object_store_backend::test_helpers::new_for_testing(store.clone() as Arc, "test-bucket".to_string()) .with_retry_config(RetryConfig { max_attempts: 3, initial_delay: Duration::from_millis(10), max_delay: Duration::from_secs(1), backoff_multiplier: 2.0, }); // Metadata should succeed after retries (but metadata doesn't use with_retry) let result = backend.metadata("test/metadata.txt").await; // Note: metadata() doesn't use with_retry in current implementation assert!(result.is_err(), "Metadata doesn't use retry logic currently"); } // Test 5: NotFound error should not trigger retry #[tokio::test] async fn test_exists_not_found_no_retry() { let store = Arc::new(FailingObjectStore::new(0, ErrorType::NotFound)); let backend = storage::object_store_backend::test_helpers::new_for_testing(store.clone() as Arc, "test-bucket".to_string()); let result = backend.exists("nonexistent.txt").await; // Should return Ok(false) for NotFound assert!(result.is_ok(), "exists should handle NotFound gracefully"); assert!(!result.unwrap(), "Should return false for non-existent file"); } // Test 6: Delete nonexistent file #[tokio::test] async fn test_delete_not_found() { let store: Arc = Arc::new(InMemory::new()); let backend = storage::object_store_backend::test_helpers::new_for_testing(store, "test-bucket".to_string()); let result = backend.delete("nonexistent.txt").await; // Should return Ok(false) for NotFound // Note: InMemory store returns Ok(()) even for non-existent files, // so delete() won't return false for InMemory. This tests the happy path. assert!(result.is_ok(), "delete should handle NotFound gracefully"); } // Test 7: Retry backoff behavior #[tokio::test] async fn test_retry_backoff_timing() { use std::time::Instant; let store = Arc::new(FailingObjectStore::new(2, ErrorType::Generic)); let backend = storage::object_store_backend::test_helpers::new_for_testing(store.clone() as Arc, "test-bucket".to_string()) .with_retry_config(RetryConfig { max_attempts: 3, initial_delay: Duration::from_millis(50), max_delay: Duration::from_secs(1), backoff_multiplier: 2.0, }); let start = Instant::now(); let _ = backend.store("test/backoff.txt", b"data").await; let elapsed = start.elapsed(); // Should take at least initial_delay + (initial_delay * backoff_multiplier) // = 50ms + 100ms = 150ms (with some tolerance) assert!( elapsed >= Duration::from_millis(140), "Should respect backoff delays, took {:?}", elapsed ); } // Test 8: Zero max_attempts should use default #[tokio::test] async fn test_retry_config_validation() { let store: Arc = Arc::new(InMemory::new()); let backend = storage::object_store_backend::test_helpers::new_for_testing(store, "test-bucket".to_string()) .with_retry_config(RetryConfig { max_attempts: 1, initial_delay: Duration::from_millis(10), max_delay: Duration::from_secs(1), backoff_multiplier: 2.0, }); let test_data = b"test data"; let result = backend.store("test/single.txt", test_data).await; assert!(result.is_ok(), "Should work with single attempt"); } // Test 9: Download with progress - failure scenarios #[tokio::test] async fn test_download_with_progress_file_not_found() { let store: Arc = Arc::new(InMemory::new()); let backend = storage::object_store_backend::test_helpers::new_for_testing(store, "test-bucket".to_string()); let result = backend.download_with_progress("nonexistent.txt", None).await; assert!(result.is_err(), "Should fail for non-existent file"); } // Test 10: Stream download failure #[tokio::test] async fn test_stream_download_file_not_found() { let store: Arc = Arc::new(InMemory::new()); let backend = storage::object_store_backend::test_helpers::new_for_testing(store, "test-bucket".to_string()); let progress_callback = Arc::new(|_downloaded: u64, _total: u64| {}); let result = backend .stream_download_with_progress("nonexistent.txt", 1024, progress_callback) .await; assert!(result.is_err(), "Should fail for non-existent file"); } // Test 11: Parallel download with empty list #[tokio::test] async fn test_parallel_download_empty_list() { let store: Arc = Arc::new(InMemory::new()); let backend = storage::object_store_backend::test_helpers::new_for_testing(store, "test-bucket".to_string()); let result = backend.parallel_download(vec![], None).await; assert!(result.is_ok(), "Should handle empty list gracefully"); assert_eq!(result.unwrap().len(), 0, "Should return empty result"); } // Test 12: Parallel download with some failures #[tokio::test] async fn test_parallel_download_partial_failure() { let store: Arc = Arc::new(InMemory::new()); let backend = storage::object_store_backend::test_helpers::new_for_testing(store.clone(), "test-bucket".to_string()); // Store only one file backend.store("file1.txt", b"data1").await.unwrap(); let paths = vec!["file1.txt".to_string(), "file2.txt".to_string()]; let result = backend.parallel_download(paths, None).await; // Should fail because file2 doesn't exist assert!(result.is_err(), "Should fail if any file is missing"); } // Test 13: Very large retry delay capping #[tokio::test] async fn test_retry_max_delay_capping() { let store = Arc::new(FailingObjectStore::new(3, ErrorType::Generic)); let backend = storage::object_store_backend::test_helpers::new_for_testing(store.clone() as Arc, "test-bucket".to_string()) .with_retry_config(RetryConfig { max_attempts: 4, initial_delay: Duration::from_millis(100), max_delay: Duration::from_millis(150), // Cap at 150ms backoff_multiplier: 10.0, // Very aggressive multiplier }); let start = std::time::Instant::now(); let _ = backend.store("test/capped.txt", b"data").await; let elapsed = start.elapsed(); // With 3 failures: delay1=100ms, delay2=150ms (capped), delay3=150ms (capped) // Total should be around 400ms, not 100 + 1000 + 10000 = 11100ms assert!( elapsed < Duration::from_millis(600), "Delays should be capped at max_delay, took {:?}", elapsed ); } // Test 14: Upload with authentication error (not retryable) #[tokio::test] async fn test_upload_auth_error_no_retry() { let store = Arc::new(FailingObjectStore::new(5, ErrorType::Unauthenticated)); let backend = storage::object_store_backend::test_helpers::new_for_testing(store.clone() as Arc, "test-bucket".to_string()) .with_retry_config(RetryConfig { max_attempts: 3, initial_delay: Duration::from_millis(10), max_delay: Duration::from_secs(1), backoff_multiplier: 2.0, }); let test_data = b"test data"; let result = backend.store("test/auth.txt", test_data).await; // Should still retry because with_retry retries all errors assert!(result.is_err(), "Should fail with auth error"); assert_eq!( store.get_attempt_count(), 3, "Should retry even for auth errors in current implementation" ); } // Test 15: List operation error handling #[tokio::test] async fn test_list_operation_error() { let store: Arc = Arc::new(InMemory::new()); let backend = storage::object_store_backend::test_helpers::new_for_testing(store, "test-bucket".to_string()); // List with invalid prefix should still work let result = backend.list("invalid/prefix/").await; assert!(result.is_ok(), "List should succeed even with empty results"); assert_eq!(result.unwrap().len(), 0, "Should return empty list"); } // Test 16: Metadata for non-existent file #[tokio::test] async fn test_metadata_not_found() { let store: Arc = Arc::new(InMemory::new()); let backend = storage::object_store_backend::test_helpers::new_for_testing(store, "test-bucket".to_string()); let result = backend.metadata("nonexistent.txt").await; assert!(result.is_err(), "Should fail for non-existent file"); } // Test 17: Exists with generic error #[tokio::test] async fn test_exists_generic_error() { let store = Arc::new(FailingObjectStore::new(1, ErrorType::Generic)); let backend = storage::object_store_backend::test_helpers::new_for_testing(store.clone() as Arc, "test-bucket".to_string()); let result = backend.exists("test.txt").await; assert!( result.is_err(), "Should propagate non-NotFound errors in exists" ); } // Test 18: Delete with generic error #[tokio::test] async fn test_delete_generic_error() { let store = Arc::new(FailingObjectStore::new(1, ErrorType::Generic)); let backend = storage::object_store_backend::test_helpers::new_for_testing(store.clone() as Arc, "test-bucket".to_string()); let result = backend.delete("test.txt").await; assert!( result.is_err(), "Should propagate non-NotFound errors in delete" ); } // Test 19: Concurrent uploads with retry #[tokio::test] async fn test_concurrent_uploads_with_retry() { let store = Arc::new(FailingObjectStore::new(1, ErrorType::Generic)); let backend = Arc::new( storage::object_store_backend::test_helpers::new_for_testing(store.clone() as Arc, "test-bucket".to_string()) .with_retry_config(RetryConfig { max_attempts: 3, initial_delay: Duration::from_millis(10), max_delay: Duration::from_secs(1), backoff_multiplier: 2.0, }), ); let mut handles = vec![]; for i in 0..5 { let backend = Arc::clone(&backend); let handle = tokio::spawn(async move { let path = format!("concurrent_{}.txt", i); let data = format!("data_{}", i); backend.store(&path, data.as_bytes()).await }); handles.push(handle); } let mut successes = 0; for handle in handles { if let Ok(Ok(_)) = handle.await { successes += 1; } } // Some should succeed after retry assert!(successes > 0, "At least some concurrent uploads should succeed"); } // Test 20: Download with progress - zero size file #[tokio::test] async fn test_download_with_progress_empty_file() { let store: Arc = Arc::new(InMemory::new()); let backend = storage::object_store_backend::test_helpers::new_for_testing(store, "test-bucket".to_string()); // Store empty file backend.store("empty.txt", b"").await.unwrap(); let progress_calls = Arc::new(AtomicUsize::new(0)); let progress_calls_clone = Arc::clone(&progress_calls); let callback = Arc::new(move |_downloaded: u64, _total: u64| { progress_calls_clone.fetch_add(1, Ordering::SeqCst); }); let result = backend.download_with_progress("empty.txt", Some(callback)).await; assert!(result.is_ok(), "Should handle empty file"); assert_eq!(result.unwrap().len(), 0, "Should return empty data"); assert!( progress_calls.load(Ordering::SeqCst) >= 2, "Should call progress callback for empty file" ); }