Skip to main content

synwire_core/runnables/
fallbacks.rs

1//! Fallback composition for runnables.
2
3use crate::BoxFuture;
4use crate::error::SynwireError;
5use crate::runnables::config::RunnableConfig;
6use crate::runnables::core::RunnableCore;
7use serde_json::Value;
8
9/// A runnable that tries a primary and falls back to alternatives on failure.
10///
11/// Tries the primary runnable first. If it returns an error, each fallback
12/// is tried in order until one succeeds. If all fail, the last error is
13/// returned.
14pub struct RunnableWithFallbacks {
15    primary: Box<dyn RunnableCore>,
16    fallbacks: Vec<Box<dyn RunnableCore>>,
17}
18
19impl RunnableWithFallbacks {
20    /// Create a new fallback composition.
21    pub fn new(primary: Box<dyn RunnableCore>, fallbacks: Vec<Box<dyn RunnableCore>>) -> Self {
22        Self { primary, fallbacks }
23    }
24}
25
26impl RunnableCore for RunnableWithFallbacks {
27    fn invoke<'a>(
28        &'a self,
29        input: Value,
30        config: Option<&'a RunnableConfig>,
31    ) -> BoxFuture<'a, Result<Value, SynwireError>> {
32        Box::pin(async move {
33            let mut last_error = match self.primary.invoke(input.clone(), config).await {
34                Ok(v) => return Ok(v),
35                Err(e) => e,
36            };
37
38            for fallback in &self.fallbacks {
39                match fallback.invoke(input.clone(), config).await {
40                    Ok(v) => return Ok(v),
41                    Err(e) => last_error = e,
42                }
43            }
44
45            Err(last_error)
46        })
47    }
48
49    #[allow(clippy::unnecessary_literal_bound)]
50    fn name(&self) -> &str {
51        "RunnableWithFallbacks"
52    }
53}
54
55/// Convenience function to compose a primary runnable with fallbacks.
56pub fn with_fallbacks(
57    primary: Box<dyn RunnableCore>,
58    fallbacks: Vec<Box<dyn RunnableCore>>,
59) -> RunnableWithFallbacks {
60    RunnableWithFallbacks::new(primary, fallbacks)
61}
62
63#[cfg(test)]
64#[allow(clippy::unwrap_used)]
65mod tests {
66    use super::*;
67    use crate::runnables::lambda::RunnableLambda;
68
69    #[tokio::test]
70    async fn test_fallback_on_primary_failure() {
71        let failing = RunnableLambda::new(|_: Value| {
72            Box::pin(async { Err(SynwireError::Other("primary failed".into())) })
73        });
74
75        let fallback =
76            RunnableLambda::new(|_: Value| Box::pin(async { Ok(Value::from("fallback_result")) }));
77
78        let composed = with_fallbacks(Box::new(failing), vec![Box::new(fallback)]);
79        let result = composed.invoke(Value::from("input"), None).await.unwrap();
80        assert_eq!(result, Value::from("fallback_result"));
81    }
82
83    #[tokio::test]
84    async fn test_primary_succeeds_no_fallback() {
85        let primary = RunnableLambda::new(|v: Value| Box::pin(async { Ok(v) }));
86        let fallback =
87            RunnableLambda::new(|_: Value| Box::pin(async { Ok(Value::from("should_not_reach")) }));
88
89        let composed = with_fallbacks(Box::new(primary), vec![Box::new(fallback)]);
90        let result = composed
91            .invoke(Value::from("original"), None)
92            .await
93            .unwrap();
94        assert_eq!(result, Value::from("original"));
95    }
96
97    #[tokio::test]
98    async fn test_all_fallbacks_fail() {
99        let failing = RunnableLambda::new(|_: Value| {
100            Box::pin(async { Err(SynwireError::Other("fail".into())) })
101        });
102        let also_failing = RunnableLambda::new(|_: Value| {
103            Box::pin(async { Err(SynwireError::Other("also fail".into())) })
104        });
105
106        let composed = with_fallbacks(Box::new(failing), vec![Box::new(also_failing)]);
107        let result = composed.invoke(Value::from("input"), None).await;
108        assert!(result.is_err());
109    }
110}