use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use tracing::{debug, info, warn};
use crate::adapters::resilience::CircuitBreakerImpl;
use crate::domain::error::{Result, ServiceError, StygianError};
use crate::ports::{CircuitBreaker, CircuitState, ScrapingService, ServiceInput, ServiceOutput};
struct ChainEntry {
service: Arc<dyn ScrapingService>,
breaker: Arc<CircuitBreakerImpl>,
}
pub struct FallbackChainService {
entries: Vec<ChainEntry>,
name: &'static str,
}
impl FallbackChainService {
pub const fn builder() -> FallbackChainBuilder {
FallbackChainBuilder::new()
}
pub const fn len(&self) -> usize {
self.entries.len()
}
pub const fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[async_trait]
impl ScrapingService for FallbackChainService {
async fn execute(&self, input: ServiceInput) -> Result<ServiceOutput> {
let mut last_err: Option<StygianError> = None;
for (idx, entry) in self.entries.iter().enumerate() {
let state = entry.breaker.state();
if state == CircuitState::Open {
if !entry.breaker.attempt_reset() {
debug!(
service = entry.service.name(),
chain = self.name,
idx,
"circuit open — skipping service in fallback chain"
);
continue;
}
debug!(
service = entry.service.name(),
chain = self.name,
idx,
"circuit half-open — probing service"
);
}
debug!(
service = entry.service.name(),
chain = self.name,
idx,
url = %input.url,
"fallback chain: attempting service"
);
match entry.service.execute(input.clone()).await {
Ok(output) => {
entry.breaker.record_success();
info!(
service = entry.service.name(),
chain = self.name,
idx,
"fallback chain: service succeeded"
);
return Ok(output);
}
Err(e) => {
entry.breaker.record_failure();
warn!(
service = entry.service.name(),
chain = self.name,
idx,
error = %e,
"fallback chain: service failed — advancing to next"
);
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| {
StygianError::Service(ServiceError::Unavailable(format!(
"fallback chain '{}' exhausted: no services registered or available",
self.name
)))
}))
}
fn name(&self) -> &'static str {
self.name
}
}
pub struct FallbackChainBuilder {
entries: Vec<ChainEntry>,
name: &'static str,
}
impl FallbackChainBuilder {
pub const fn new() -> Self {
Self {
entries: Vec::new(),
name: "fallback-chain",
}
}
#[must_use]
pub fn add(mut self, service: Arc<dyn ScrapingService>, breaker: CircuitBreakerImpl) -> Self {
self.entries.push(ChainEntry {
service,
breaker: Arc::new(breaker),
});
self
}
#[must_use]
pub const fn named(mut self, name: &'static str) -> Self {
self.name = name;
self
}
pub fn build(self) -> FallbackChainService {
FallbackChainService {
entries: self.entries,
name: self.name,
}
}
}
impl Default for FallbackChainBuilder {
fn default() -> Self {
Self::new()
}
}
pub fn default_primary_breaker() -> CircuitBreakerImpl {
CircuitBreakerImpl::new(5, Duration::from_secs(30))
}
pub fn default_fallback_breaker() -> CircuitBreakerImpl {
#[allow(clippy::duration_suboptimal_units)]
{
CircuitBreakerImpl::new(3, Duration::from_secs(60))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapters::noop::NoopService;
use crate::domain::error::ServiceError;
use crate::ports::{ServiceInput, ServiceOutput};
use serde_json::json;
struct AlwaysFailService;
#[async_trait]
impl ScrapingService for AlwaysFailService {
async fn execute(&self, _input: ServiceInput) -> Result<ServiceOutput> {
Err(StygianError::Service(ServiceError::Unavailable(
"simulated failure".into(),
)))
}
fn name(&self) -> &'static str {
"always-fail"
}
}
fn make_input() -> ServiceInput {
ServiceInput {
url: "https://example.com".to_string(),
params: json!({}),
}
}
#[tokio::test]
async fn test_first_service_succeeds() -> Result<()> {
let chain = FallbackChainService::builder()
.add(
Arc::new(NoopService),
CircuitBreakerImpl::new(5, Duration::from_secs(30)),
)
.add(
Arc::new(AlwaysFailService),
CircuitBreakerImpl::new(5, Duration::from_secs(30)),
)
.build();
let output = chain.execute(make_input()).await?;
match output.metadata.get("service") {
Some(service) => assert_eq!(service, "noop", "noop should win"),
None => {
return Err(
ServiceError::Unavailable("service key should exist".to_string()).into(),
);
}
}
Ok(())
}
#[tokio::test]
async fn test_fallback_fires_when_primary_fails() -> Result<()> {
let chain = FallbackChainService::builder()
.add(
Arc::new(AlwaysFailService),
CircuitBreakerImpl::new(5, Duration::from_secs(30)),
)
.add(
Arc::new(NoopService),
CircuitBreakerImpl::new(5, Duration::from_secs(30)),
)
.named("primary-then-noop")
.build();
let output = chain.execute(make_input()).await?;
match output.metadata.get("service") {
Some(service) => assert_eq!(
service, "noop",
"fallback noop should win after primary failure"
),
None => {
return Err(
ServiceError::Unavailable("service key should exist".to_string()).into(),
);
}
}
Ok(())
}
#[tokio::test]
async fn test_all_services_fail_returns_error() {
let chain = FallbackChainService::builder()
.add(
Arc::new(AlwaysFailService),
CircuitBreakerImpl::new(5, Duration::from_secs(30)),
)
.add(
Arc::new(AlwaysFailService),
CircuitBreakerImpl::new(5, Duration::from_secs(30)),
)
.build();
let result = chain.execute(make_input()).await;
assert!(result.is_err(), "all-failing chain must return error");
}
#[tokio::test]
async fn test_empty_chain_returns_unavailable() {
let chain = FallbackChainService::builder().build();
let result = chain.execute(make_input()).await;
assert!(
result.is_err(),
"empty chain must return ServiceError::Unavailable"
);
}
#[tokio::test]
async fn test_chain_name_default() {
let chain = FallbackChainService::builder().build();
assert_eq!(chain.name(), "fallback-chain");
}
#[tokio::test]
async fn test_chain_name_custom() {
let chain = FallbackChainService::builder()
.named("http-to-plugin")
.build();
assert_eq!(chain.name(), "http-to-plugin");
}
#[tokio::test]
async fn test_open_circuit_skipped_advances_to_next() -> Result<()> {
let failing_breaker = CircuitBreakerImpl::new(1, {
#[allow(clippy::duration_suboptimal_units)]
{
Duration::from_secs(3600)
} });
failing_breaker.record_failure();
assert_eq!(
failing_breaker.state(),
CircuitState::Open,
"breaker should be open after threshold hit"
);
let chain = FallbackChainService::builder()
.add(Arc::new(AlwaysFailService), failing_breaker)
.add(
Arc::new(NoopService),
CircuitBreakerImpl::new(5, Duration::from_secs(30)),
)
.named("open-circuit-skip-test")
.build();
let output = chain.execute(make_input()).await?;
match output.metadata.get("service") {
Some(service) => assert_eq!(
service, "noop",
"open-circuit service must be skipped; noop must serve the request"
),
None => {
return Err(
ServiceError::Unavailable("service key should exist".to_string()).into(),
);
}
}
Ok(())
}
#[tokio::test]
async fn test_circuit_records_success_on_recovery() -> Result<()> {
let chain = FallbackChainService::builder()
.add(
Arc::new(NoopService),
CircuitBreakerImpl::new(5, Duration::from_secs(30)),
)
.build();
chain.execute(make_input()).await?;
chain.execute(make_input()).await?;
Ok(())
}
#[tokio::test]
async fn test_len_and_is_empty() {
let empty = FallbackChainService::builder().build();
assert!(empty.is_empty());
assert_eq!(empty.len(), 0);
let one = FallbackChainService::builder()
.add(
Arc::new(NoopService),
CircuitBreakerImpl::new(5, Duration::from_secs(30)),
)
.build();
assert!(!one.is_empty());
assert_eq!(one.len(), 1);
}
#[tokio::test]
async fn test_default_breaker_helpers() {
let primary = default_primary_breaker();
let fallback = default_fallback_breaker();
assert_eq!(primary.state(), CircuitState::Closed);
assert_eq!(fallback.state(), CircuitState::Closed);
}
}