#![ allow( clippy::doc_markdown ) ]
#[ cfg( feature = "integration" ) ]
use api_huggingface::{
Client,
environment::HuggingFaceEnvironmentImpl,
providers::ChatMessage,
reliability::{
FailoverManager,
FailoverConfig,
FailoverStrategy,
FailoverError,
},
Secret,
};
#[ cfg( feature = "integration" ) ]
use core::time::Duration;
#[ cfg( feature = "integration" ) ]
use std::sync::Arc;
#[ cfg( feature = "integration" ) ]
fn create_test_client() -> Client< HuggingFaceEnvironmentImpl >
{
use workspace_tools as workspace;
let workspace = workspace::workspace()
.expect( "[create_test_client] Failed to access workspace - required for integration tests" );
let secrets = workspace.load_secrets_from_file( "-secrets.sh" )
.expect( "[create_test_client] Failed to load secret/-secrets.sh - required for integration tests" );
let api_key = secrets.get( "HUGGINGFACE_API_KEY" )
.expect( "[create_test_client] HUGGINGFACE_API_KEY not found in secret/-secrets.sh - required for integration tests. Get your token from https://huggingface.co/settings/tokens" )
.clone();
let env = HuggingFaceEnvironmentImpl::build( Secret::new( api_key ), None )
.expect( "Environment creation should succeed" );
Client::build( env )
.expect( "Client creation should succeed" )
}
#[ cfg( feature = "integration" ) ]
fn create_test_messages() -> Vec< ChatMessage >
{
vec![
ChatMessage {
role : "user".to_string( ),
content : "Hello, how are you?".to_string( ),
tool_calls : None,
tool_call_id : None,
}
]
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_priority_failover_first_endpoint_succeeds()
{
let client = create_test_client( );
let config = FailoverConfig {
endpoints : vec![
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
],
strategy : FailoverStrategy::Priority,
max_retries : 3,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 3,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
let result = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
assert!( result.is_ok( ), "First endpoint should succeed" );
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_priority_failover_first_fails_second_succeeds()
{
let client = create_test_client( );
let config = FailoverConfig {
endpoints : vec![
"invalid-model-xyz-12345".to_string( ),
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
],
strategy : FailoverStrategy::Priority,
max_retries : 2,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 1,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
let result = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
assert!( result.is_ok( ), "Should succeed with second endpoint after first fails" );
let health = failover.health_status( ).await;
assert_eq!( health.len( ), 2 );
assert!( !health[0 ].healthy, "First endpoint should be unhealthy" );
assert!( health[1 ].healthy, "Second endpoint should be healthy" );
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_priority_failover_all_endpoints_fail()
{
let client = create_test_client( );
let config = FailoverConfig {
endpoints : vec![
"invalid-model-1".to_string( ),
"invalid-model-2".to_string( ),
],
strategy : FailoverStrategy::Priority,
max_retries : 2,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 1,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
let result = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
assert!( result.is_err( ), "Should fail when all endpoints fail" );
match result
{
Err( FailoverError::AllRetriesFailed { attempts, .. } ) => {
assert!( attempts > 0, "Should have attempted at least one request" );
}
_ => panic!( "Expected AllRetriesFailed error" ),
}
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_round_robin_cycles_through_endpoints()
{
let client = create_test_client( );
let config = FailoverConfig {
endpoints : vec![
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
"Qwen/Qwen2.5-72B-Instruct".to_string( ),
],
strategy : FailoverStrategy::RoundRobin,
max_retries : 1,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 5,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
for _ in 0..4
{
let result = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
assert!( result.is_ok( ), "Request should succeed" );
}
let health = failover.health_status( ).await;
assert!( health[0 ].requests > 0, "First endpoint should have requests" );
assert!( health[1 ].requests > 0, "Second endpoint should have requests" );
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_round_robin_skips_unhealthy_endpoints()
{
let client = create_test_client( );
let config = FailoverConfig {
endpoints : vec![
"invalid-model-xyz".to_string( ),
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
],
strategy : FailoverStrategy::RoundRobin,
max_retries : 2,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 1,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
for _ in 0..3
{
let result = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
assert!( result.is_ok( ), "Should succeed with healthy endpoints" );
}
let health = failover.health_status( ).await;
assert!( !health[0 ].healthy, "First endpoint should be unhealthy" );
assert!( health[1 ].healthy, "Second endpoint should be healthy" );
assert!( health[2 ].healthy, "Third endpoint should be healthy" );
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_random_strategy_uses_random_endpoints()
{
let client = create_test_client( );
let config = FailoverConfig {
endpoints : vec![
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
],
strategy : FailoverStrategy::Random,
max_retries : 1,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 5,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
for _ in 0..5
{
let result = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
assert!( result.is_ok( ), "Request should succeed" );
}
let health = failover.health_status( ).await;
let total_requests : u64 = health.iter( ).map( |h| h.requests ).sum( );
assert_eq!( total_requests, 5, "Should have made 5 requests total" );
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_random_strategy_avoids_unhealthy_endpoints()
{
let client = create_test_client( );
let config = FailoverConfig {
endpoints : vec![
"invalid-model-xyz".to_string( ),
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
],
strategy : FailoverStrategy::Random,
max_retries : 2,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 1,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
failover.record_failure( "invalid-model-xyz" ).await;
let health = failover.health_status( ).await;
assert!( !health[ 0 ].healthy, "Invalid endpoint should be pre-marked unhealthy" );
for _ in 0..4
{
let result = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
assert!( result.is_ok( ), "Should succeed with healthy endpoint" );
}
let health = failover.health_status( ).await;
assert!( !health[ 0 ].healthy, "Invalid endpoint should remain unhealthy" );
assert!( health[ 1 ].healthy, "Valid endpoint should be healthy" );
assert_eq!( health[ 1 ].successes, 4, "All 4 successes should be on healthy endpoint" );
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_sticky_strategy_uses_same_endpoint()
{
let client = create_test_client( );
let config = FailoverConfig {
endpoints : vec![
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
],
strategy : FailoverStrategy::Sticky,
max_retries : 1,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 3,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
for _ in 0..5
{
let result = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
assert!( result.is_ok( ), "Request should succeed" );
}
let health = failover.health_status( ).await;
let endpoints_used : Vec< _ > = health.iter( )
.filter( |h| h.requests > 0 )
.collect( );
assert_eq!( endpoints_used.len( ), 1, "Should stick to one endpoint" );
assert_eq!( endpoints_used[0 ].requests, 5, "Should have all 5 requests" );
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_sticky_strategy_switches_on_failure()
{
let client = create_test_client( );
let config = FailoverConfig {
endpoints : vec![
"invalid-model-xyz".to_string( ),
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
],
strategy : FailoverStrategy::Sticky,
max_retries : 2,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 1,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
let result = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
assert!( result.is_ok( ), "Should failover to second endpoint" );
for _ in 0..3
{
let result = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
assert!( result.is_ok( ), "Should stick to healthy endpoint" );
}
let health = failover.health_status( ).await;
assert!( !health[0 ].healthy, "First endpoint should be unhealthy" );
assert!( health[1 ].healthy, "Second endpoint should be healthy" );
assert_eq!( health[1 ].successes, 4, "All 4 successes should be on second endpoint" );
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_health_tracking_marks_unhealthy_after_threshold()
{
let client = create_test_client( );
let config = FailoverConfig {
endpoints : vec![
"invalid-model-xyz".to_string( ),
],
strategy : FailoverStrategy::Priority,
max_retries : 0,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 2,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
let _ = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
let health = failover.health_status( ).await;
assert!( health[0 ].healthy, "Should still be healthy after first failure" );
let _ = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
let health = failover.health_status( ).await;
assert!( !health[0 ].healthy, "Should be unhealthy after reaching threshold" );
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_health_tracking_recovers_after_success()
{
let client = create_test_client( );
let config = FailoverConfig {
endpoints : vec![
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
],
strategy : FailoverStrategy::Priority,
max_retries : 0,
failure_window : Duration::from_secs( 1 ),
failure_threshold : 1,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
{
let endpoint = failover.select_endpoint( ).await.ok( );
if let Some( ep ) = endpoint
{
failover.record_failure( &ep ).await;
}
}
let health = failover.health_status( ).await;
assert!( !health[0 ].healthy, "Should be unhealthy after manual failure" );
tokio::time::sleep( Duration::from_secs( 2 )).await;
let result = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
assert!( result.is_ok( ), "Request should succeed" );
let health = failover.health_status( ).await;
assert!( health[0 ].healthy, "Should be healthy after success" );
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_health_status_reflects_request_counts()
{
let client = create_test_client( );
let config = FailoverConfig {
endpoints : vec![
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
],
strategy : FailoverStrategy::Priority,
max_retries : 0,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 5,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
for _ in 0..3
{
let _ = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
}
let health = failover.health_status( ).await;
assert_eq!( health[0 ].requests, 3, "Should have 3 requests" );
assert_eq!( health[0 ].successes, 3, "Should have 3 successes" );
assert!( health[0 ].healthy, "Should be healthy" );
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_reset_clears_health_tracking()
{
let config = FailoverConfig {
endpoints : vec![
"endpoint1".to_string( ),
"endpoint2".to_string( ),
],
strategy : FailoverStrategy::Priority,
max_retries : 3,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 2,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
failover.record_failure( "endpoint1" ).await;
failover.record_failure( "endpoint1" ).await;
let health = failover.health_status( ).await;
assert!( !health[0 ].healthy, "Should be unhealthy after failures" );
failover.reset( ).await;
let health = failover.health_status( ).await;
assert!( health[0 ].healthy, "Should be healthy after reset" );
assert_eq!( health[0 ].requests, 0, "Requests should be reset" );
assert_eq!( health[0 ].successes, 0, "Successes should be reset" );
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_concurrent_requests_with_failover()
{
let client = Arc::new( create_test_client( ));
let config = FailoverConfig {
endpoints : vec![
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
],
strategy : FailoverStrategy::RoundRobin,
max_retries : 1,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 5,
};
let failover = Arc::new( FailoverManager::new( config ).expect( "Failover creation should succeed" ));
let mut handles = vec![ ];
for _ in 0..5
{
let client = client.clone( );
let failover = failover.clone( );
let handle = tokio::spawn( async move {
failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await
} );
handles.push( handle );
}
let mut successes = 0;
for handle in handles
{
if let Ok( Ok( _ )) = handle.await
{
successes += 1;
}
}
assert!( successes >= 4, "Most concurrent requests should succeed" );
let health = failover.health_status( ).await;
let total_requests : u64 = health.iter( ).map( |h| h.requests ).sum( );
assert_eq!( total_requests, 5, "Should have processed 5 requests" );
}
#[ test ]
fn test_failover_backoff_delay_no_overflow_for_high_attempt_counts()
{
for attempt in 1u32..=100
{
let exp = ( attempt - 1 ).min( 13 );
let delay_ms = 500u64 * 2u64.pow( exp );
let _capped = delay_ms.min( 5000 );
}
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_empty_endpoints_list()
{
let config = FailoverConfig {
endpoints : vec![ ],
strategy : FailoverStrategy::Priority,
max_retries : 3,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 3,
};
let result = FailoverManager::new( config );
assert!( result.is_err( ), "Should fail to create failover with empty endpoints" );
match result
{
Err( FailoverError::NoEndpoints ) => {}
_ => panic!( "Expected NoEndpoints error" ),
}
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_single_endpoint_failover()
{
let _client = create_test_client( );
let config = FailoverConfig {
endpoints : vec![
"meta-llama/Llama-3.3-70B-Instruct".to_string( ),
],
strategy : FailoverStrategy::Priority,
max_retries : 2,
failure_window : Duration::from_secs( 300 ),
failure_threshold : 5,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
let endpoint = failover.select_endpoint( ).await;
assert!( endpoint.is_ok( ), "Should select single endpoint" );
}
#[ cfg( feature = "integration" ) ]
#[ tokio::test ]
async fn test_max_retries_limits_attempts()
{
let client = create_test_client( );
let config = FailoverConfig {
endpoints : vec![
"invalid-model-1".to_string( ),
"invalid-model-2".to_string( ),
"invalid-model-3".to_string( ),
],
strategy : FailoverStrategy::Priority,
max_retries : 1, failure_window : Duration::from_secs( 300 ),
failure_threshold : 1,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
let result = failover.execute_with_failover( |model| {
let client = client.clone( );
let messages = create_test_messages( );
Box::pin( async move {
client.providers( ).chat_completion(
&model,
messages,
Some( 20 ),
None,
None,
).await
} )
} ).await;
assert!( result.is_err( ), "Should fail after max retries" );
match result
{
Err( FailoverError::AllRetriesFailed { attempts, .. } ) => {
assert!( attempts <= 2, "Should respect max_retries limit" );
}
_ => panic!( "Expected AllRetriesFailed error" ),
}
}
#[ cfg( feature = "failover" ) ]
#[ tokio::test ]
async fn test_record_on_unknown_endpoint_is_noop()
{
use api_huggingface::reliability::{ FailoverManager, FailoverConfig, FailoverStrategy };
use core::time::Duration;
let config = FailoverConfig
{
endpoints : vec![ "https://known.endpoint".to_string( ) ],
strategy : FailoverStrategy::Priority,
max_retries : 3,
failure_window : Duration::from_secs( 60 ),
failure_threshold : 5,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
failover.record_failure( "https://not.in.list" ).await;
failover.record_success( "https://not.in.list" ).await;
let health = failover.health_status( ).await;
assert_eq!( health.len(), 1, "still one endpoint" );
assert!( health[ 0 ].healthy, "known endpoint must still be healthy" );
assert_eq!( health[ 0 ].requests, 0, "known endpoint must have zero recorded requests" );
let selected = failover.select_endpoint( ).await;
assert!( selected.is_ok( ), "should still select the healthy endpoint" );
assert_eq!( selected.unwrap( ), "https://known.endpoint" );
}
#[ cfg( feature = "failover" ) ]
#[ tokio::test ]
async fn test_round_robin_single_endpoint_selects_correctly()
{
use api_huggingface::reliability::{ FailoverManager, FailoverConfig, FailoverStrategy };
use core::time::Duration;
let config = FailoverConfig
{
endpoints : vec![ "https://api.example.com".to_string( ) ],
strategy : FailoverStrategy::RoundRobin,
max_retries : 3,
failure_window : Duration::from_secs( 60 ),
failure_threshold : 5,
};
let failover = FailoverManager::new( config ).expect( "Failover creation should succeed" );
for _ in 0..3
{
let result = failover.select_endpoint( ).await;
assert!( result.is_ok( ), "Single healthy endpoint must always be selected" );
assert_eq!( result.unwrap( ), "https://api.example.com" );
}
}
#[ cfg( feature = "failover" ) ]
#[ tokio::test ]
async fn test_all_endpoints_unhealthy_returns_correct_result_per_strategy()
{
use api_huggingface::reliability::{
FailoverManager, FailoverConfig, FailoverStrategy, FailoverError,
};
use core::time::Duration;
let endpoints = vec![
"https://ep1.example.com".to_string( ),
"https://ep2.example.com".to_string( ),
];
{
let config = FailoverConfig
{
endpoints : endpoints.clone( ),
strategy : FailoverStrategy::RoundRobin,
max_retries : 3,
failure_window : Duration::from_secs( 60 ),
failure_threshold : 1,
};
let failover = FailoverManager::new( config ).expect( "Creation must succeed" );
failover.record_failure( "https://ep1.example.com" ).await;
failover.record_failure( "https://ep2.example.com" ).await;
let result = failover.select_endpoint( ).await;
assert!(
matches!( result, Err( FailoverError::AllEndpointsUnhealthy ) ),
"RoundRobin must return AllEndpointsUnhealthy when all endpoints are down"
);
}
{
let config = FailoverConfig
{
endpoints : endpoints.clone( ),
strategy : FailoverStrategy::Random,
max_retries : 3,
failure_window : Duration::from_secs( 60 ),
failure_threshold : 1,
};
let failover = FailoverManager::new( config ).expect( "Creation must succeed" );
failover.record_failure( "https://ep1.example.com" ).await;
failover.record_failure( "https://ep2.example.com" ).await;
let result = failover.select_endpoint( ).await;
assert!(
matches!( result, Err( FailoverError::AllEndpointsUnhealthy ) ),
"Random must return AllEndpointsUnhealthy when all endpoints are down"
);
}
{
let config = FailoverConfig
{
endpoints : endpoints.clone( ),
strategy : FailoverStrategy::Sticky,
max_retries : 3,
failure_window : Duration::from_secs( 60 ),
failure_threshold : 1,
};
let failover = FailoverManager::new( config ).expect( "Creation must succeed" );
failover.record_failure( "https://ep1.example.com" ).await;
failover.record_failure( "https://ep2.example.com" ).await;
let result = failover.select_endpoint( ).await;
assert!(
matches!( result, Err( FailoverError::AllEndpointsUnhealthy ) ),
"Sticky must return AllEndpointsUnhealthy when all endpoints are down"
);
}
{
let config = FailoverConfig
{
endpoints : endpoints.clone( ),
strategy : FailoverStrategy::Priority,
max_retries : 3,
failure_window : Duration::from_secs( 60 ),
failure_threshold : 1,
};
let failover = FailoverManager::new( config ).expect( "Creation must succeed" );
failover.record_failure( "https://ep1.example.com" ).await;
failover.record_failure( "https://ep2.example.com" ).await;
let result = failover.select_endpoint( ).await;
assert!(
result.is_ok( ),
"Priority must fall back to first endpoint even when all are unhealthy"
);
assert_eq!( result.unwrap( ), "https://ep1.example.com" );
}
}