Files
foxhunt/storage/tests/s3_tests.rs
jgrusewski 8b9abcc3c1 fix: resolve all clippy errors across 37+ workspace crates
Eliminate ~4,260 clippy deny-level errors that blocked workspace-wide
clippy runs. Errors cascaded: upstream crate failures (ctrader-openapi,
risk-data) hid thousands of downstream errors in ml, tli, backtesting.

Key changes:
- ctrader-openapi: fix shadow_unrelated/shadow_reuse (renamed vars)
- risk-data/risk: replace non-ASCII em dashes with ASCII equivalents
- tli: allow deny lints on prost-generated proto code, fix shadows
- trading_engine: fix let_underscore_must_use, wildcard matches, shadows
- broker_gateway_service: allow dead_code on unused redis_client field
- ml (4030 errors): remove local deny overrides for unwrap/expect/indexing
  (workspace warn level sufficient), add crate-level allows for non-safety
  mass-violation lints (non_ascii_literal, shadow_*, str_to_string, etc.),
  batch-fix em dashes, unseparated literal suffixes, format_push_string,
  wildcard matches, impl_trait_in_params, mutex_atomic, and more
- backtesting: replace unwrap() on first()/last() with match destructure
- tests: simplify loop-that-never-loops, fix mutex unwrap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:44:10 +01:00

726 lines
22 KiB
Rust

//! 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 object_store::memory::InMemory;
use object_store::path::Path;
use object_store::{
Error as ObjectStoreError, GetOptions, GetResult, ListResult, ObjectMeta,
ObjectStore, PutOptions, PutResult,
};
use storage::model_helpers::RetryConfig;
use storage::Storage;
use tokio::sync::Mutex;
/// Mock ObjectStore that simulates transient failures
#[derive(Debug)]
struct FailingObjectStore {
inner: Arc<InMemory>,
/// Number of failures before success
failures_before_success: Arc<Mutex<usize>>,
/// Count of attempts made
attempt_count: Arc<AtomicUsize>,
/// Type of error to simulate
error_type: Arc<Mutex<ErrorType>>,
}
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,
Unauthenticated,
}
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::other("generic error")),
},
ErrorType::Unauthenticated => ObjectStoreError::Unauthenticated {
path: "test/path".to_string(),
source: Box::new(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"unauthenticated",
)),
},
}
}
}
#[async_trait::async_trait]
impl ObjectStore for FailingObjectStore {
async fn put(&self, location: &Path, payload: PutPayload) -> object_store::Result<PutResult> {
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<PutResult> {
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<GetResult> {
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<GetResult> {
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<usize>,
) -> object_store::Result<Bytes> {
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<ObjectMeta> {
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<ObjectMeta>> {
// 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<ListResult> {
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<Box<dyn object_store::MultipartUpload>> {
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<dyn ObjectStore>,
"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<dyn ObjectStore>,
"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<dyn ObjectStore>,
"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<dyn ObjectStore>,
"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<dyn ObjectStore>,
"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<dyn ObjectStore> = 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<dyn ObjectStore>,
"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<dyn ObjectStore> = 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<dyn ObjectStore> = 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<dyn ObjectStore> = 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<dyn ObjectStore> = 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<dyn ObjectStore> = 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<dyn ObjectStore>,
"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<dyn ObjectStore>,
"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<dyn ObjectStore> = 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<dyn ObjectStore> = 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<dyn ObjectStore>,
"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<dyn ObjectStore>,
"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<dyn ObjectStore>,
"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<dyn ObjectStore> = 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"
);
}