Skip to main content

switchyard_libsy/
error.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Typed failures surfaced by libsy's orchestration APIs.
5
6use std::{error::Error as StdError, time::Duration};
7
8use switchyard_protocol::LlmClientError;
9use thiserror::Error;
10
11/// Result type returned by libsy APIs.
12pub type Result<T> = std::result::Result<T, LibsyError>;
13
14/// Failures surfaced while selecting a route, driving an algorithm, or serving a model call.
15#[derive(Debug, Error)]
16pub enum LibsyError {
17    /// A named target was not present in the configured target set.
18    #[error("target {target:?} was not found")]
19    TargetNotFound {
20        /// Missing semantic target name.
21        target: String,
22    },
23
24    /// Routing was attempted without any configured targets.
25    #[error("no routing targets are configured")]
26    NoTargets,
27
28    /// An algorithm could not complete for an algorithm-specific reason.
29    #[error("{message}")]
30    AlgorithmError {
31        /// Description of the algorithm failure.
32        message: String,
33    },
34
35    /// A routed target had no default client for [`crate::Algorithm::run`].
36    #[error("target {target:?} has no client to serve the call")]
37    MissingClient {
38        /// Target that could not be served.
39        target: String,
40    },
41
42    /// The type-erased offload driver could not complete an operation.
43    #[error(transparent)]
44    Driver(#[from] DriverError),
45
46    /// The spawned algorithm task failed before returning normally.
47    #[error("algorithm task failed: {source}")]
48    AlgorithmTask {
49        /// Tokio task failure, including panic and unexpected cancellation details.
50        #[from]
51        source: tokio::task::JoinError,
52    },
53
54    /// An algorithm's step stream ended without a terminal response.
55    #[error("algorithm run ended without a final response")]
56    MissingFinalResponse,
57
58    /// A target's protocol client failed while serving a routed request.
59    #[error("client call to target {target:?} failed: {source}")]
60    ClientCall {
61        /// Target whose client failed.
62        target: String,
63        /// Typed error supplied by the protocol-owned client trait.
64        #[source]
65        source: LlmClientError,
66    },
67
68    /// Every target overflowed its context window.
69    #[error("every target exceeded its context window")]
70    AllTargetsExcluded,
71
72    /// A user extension or other foreign operation failed.
73    #[error("{operation} failed: {source}")]
74    External {
75        /// Short description of the operation that failed.
76        operation: &'static str,
77        /// Original failure.
78        #[source]
79        source: Box<dyn StdError + Send + Sync>,
80    },
81}
82
83impl LibsyError {
84    /// Wrap an error returned by the protocol-owned client trait.
85    pub fn client_call(target: impl Into<String>, source: LlmClientError) -> Self {
86        Self::ClientCall {
87            target: target.into(),
88            source,
89        }
90    }
91
92    /// Preserve a concrete foreign error with a description of the failed operation.
93    pub fn external(
94        operation: &'static str,
95        source: impl StdError + Send + Sync + 'static,
96    ) -> Self {
97        Self::External {
98            operation,
99            source: Box::new(source),
100        }
101    }
102}
103
104/// Failures in the type-erased promise-over-stream driver.
105#[derive(Debug, Error, PartialEq, Eq)]
106pub enum DriverError {
107    /// A producer operation was attempted before taking the consumer stream.
108    #[error("driver stream must be taken before calling producer methods")]
109    NotStarted,
110
111    /// The consumer side of the step channel was dropped.
112    #[error("driver stream is closed")]
113    StreamClosed,
114
115    /// The single-consumer stream had already been taken.
116    #[error("driver stream was already taken")]
117    StreamAlreadyTaken,
118
119    /// One side of a response promise was dropped before delivery.
120    #[error("driver response promise was dropped")]
121    ResponseDropped,
122
123    /// A consumer did not fulfill a request before its deadline.
124    #[error("driver response timed out after {timeout:?}")]
125    ResponseTimedOut {
126        /// Maximum time allowed for request fulfillment.
127        timeout: Duration,
128    },
129
130    /// A type-erased payload did not contain the expected concrete type.
131    #[error("driver payload type mismatch: expected {expected}")]
132    TypeMismatch {
133        /// Human-readable expected payload type or role.
134        expected: &'static str,
135    },
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn client_call_preserves_target_and_source() {
144        let error = LibsyError::client_call(
145            "strong",
146            LlmClientError::General("upstream down".to_string()),
147        );
148        match &error {
149            LibsyError::ClientCall { target, source } => {
150                assert_eq!(target, "strong");
151                assert_eq!(source.to_string(), "upstream down");
152            }
153            other => panic!("expected ClientCall, got {other:?}"),
154        }
155        assert_eq!(
156            StdError::source(&error).map(ToString::to_string),
157            Some("upstream down".to_string())
158        );
159    }
160
161    #[test]
162    fn external_preserves_operation_and_source() {
163        let error = LibsyError::external(
164            "loading extension",
165            std::io::Error::other("bad configuration"),
166        );
167        assert!(matches!(
168            &error,
169            LibsyError::External { operation, .. } if *operation == "loading extension"
170        ));
171        assert_eq!(
172            StdError::source(&error).map(ToString::to_string),
173            Some("bad configuration".to_string())
174        );
175    }
176}