Skip to main content

switchyard_runner/
failure.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Safe, structured summaries of route-execution failures for telemetry.
5
6use libsy::LibsyError;
7use strum_macros::IntoStaticStr;
8use switchyard_protocol::{LlmClientError, ModelId};
9
10use crate::RunnerError;
11
12/// Stable class of a terminal route-execution failure.
13///
14/// This deliberately carries no provider message, response body, or source
15/// error. It is suitable for logs and telemetry, not client-facing rendering.
16#[non_exhaustive]
17#[derive(Clone, Copy, Debug, IntoStaticStr)]
18#[strum(serialize_all = "snake_case")]
19pub enum RouteErrorKind {
20    /// The upstream returned a non-success HTTP response.
21    UpstreamHttp,
22    /// The selected target rejected the request because its context window was exceeded.
23    ContextWindowExceeded,
24    /// The selected backend is temporarily unavailable.
25    TemporarilyUnavailable,
26    /// The upstream request timed out.
27    Timeout,
28    /// The upstream could not be reached or the request could not be sent.
29    Transport,
30    /// The upstream response could not be decoded or validated.
31    InvalidResponse,
32    /// Decoding the inbound request failed in translation.
33    RequestTranslation,
34    /// Encoding the request for the upstream failed in translation.
35    RequestEncoding,
36    /// Decoding or encoding the response failed in translation.
37    ResponseTranslation,
38    /// The request cannot be served as supplied.
39    InvalidRequest,
40    /// The configured route or client cannot serve the request.
41    Configuration,
42    /// The routing algorithm or driver could not produce an outcome.
43    Algorithm,
44    /// A failure without a safe, more specific kind.
45    Other,
46}
47
48impl RouteErrorKind {
49    /// Returns the stable telemetry value for this kind.
50    pub fn as_str(self) -> &'static str {
51        self.into()
52    }
53}
54
55/// When a terminal failure occurred relative to response delivery.
56#[non_exhaustive]
57#[derive(Clone, Copy, Debug, IntoStaticStr)]
58#[strum(serialize_all = "snake_case")]
59pub enum RouteErrorPhase {
60    /// The route failed before returning a response to its caller.
61    BeforeResponse,
62    /// A previously returned streaming response failed while it was consumed.
63    DuringStream,
64}
65
66impl RouteErrorPhase {
67    /// Returns the stable telemetry value for this phase.
68    pub fn as_str(self) -> &'static str {
69        self.into()
70    }
71}
72
73/// Safe, structured terminal-failure data for routing telemetry.
74#[non_exhaustive]
75#[derive(Clone, Debug)]
76pub struct RouteErrorSummary {
77    /// Stable failure classification.
78    pub kind: RouteErrorKind,
79    /// Whether the error preceded response delivery or occurred while streaming.
80    pub phase: RouteErrorPhase,
81    /// Upstream HTTP status, when directly available.
82    pub upstream_status: Option<u16>,
83    /// Selected target that failed, when the runner knows it.
84    pub target: Option<ModelId>,
85}
86
87impl RunnerError {
88    /// Returns a safe telemetry summary for a failure before response delivery.
89    pub fn execution_error_summary(&self) -> RouteErrorSummary {
90        match self {
91            Self::Algorithm(LibsyError::ClientCall { target, source }) => {
92                client_error_summary(source, RouteErrorPhase::BeforeResponse, Some(target))
93            }
94            Self::Client(source) => {
95                client_error_summary(source, RouteErrorPhase::BeforeResponse, None)
96            }
97            Self::Configuration { .. } => summary(
98                RouteErrorKind::Configuration,
99                RouteErrorPhase::BeforeResponse,
100                None,
101                None,
102            ),
103            Self::UnknownRouteModel(_)
104            | Self::IncompatibleCallerFormat(_)
105            | Self::AuxiliaryUnsupported => summary(
106                RouteErrorKind::InvalidRequest,
107                RouteErrorPhase::BeforeResponse,
108                None,
109                None,
110            ),
111            Self::Algorithm(_) => summary(
112                RouteErrorKind::Algorithm,
113                RouteErrorPhase::BeforeResponse,
114                None,
115                None,
116            ),
117        }
118    }
119}
120
121/// Returns a safe telemetry summary for an error yielded by an active response stream.
122///
123/// `served_model` should be the target recorded on the response before its stream was returned.
124pub fn stream_error_summary(
125    error: &LlmClientError,
126    served_model: Option<&ModelId>,
127) -> RouteErrorSummary {
128    client_error_summary(error, RouteErrorPhase::DuringStream, served_model)
129}
130
131fn client_error_summary(
132    error: &LlmClientError,
133    phase: RouteErrorPhase,
134    target: Option<&ModelId>,
135) -> RouteErrorSummary {
136    let target = target.or(match error {
137        // Context-window errors carry the resolved target model when a caller has not
138        // already supplied the route's selected or served target.
139        LlmClientError::ContextWindowExceeded { model, .. } => Some(model),
140        _ => None,
141    });
142    let (kind, upstream_status) = match error {
143        LlmClientError::UpstreamHttp { status, .. } => {
144            (RouteErrorKind::UpstreamHttp, Some(status.as_u16()))
145        }
146        LlmClientError::ContextWindowExceeded { .. } => {
147            (RouteErrorKind::ContextWindowExceeded, None)
148        }
149        LlmClientError::TemporarilyUnavailable => (RouteErrorKind::TemporarilyUnavailable, None),
150        LlmClientError::Timeout { .. } => (RouteErrorKind::Timeout, None),
151        LlmClientError::Transport { .. } => (RouteErrorKind::Transport, None),
152        LlmClientError::InvalidResponse { .. } => (RouteErrorKind::InvalidResponse, None),
153        LlmClientError::RequestTranslation(_) => (RouteErrorKind::RequestTranslation, None),
154        LlmClientError::RequestEncoding(_) => (RouteErrorKind::RequestEncoding, None),
155        LlmClientError::ResponseTranslation(_) => (RouteErrorKind::ResponseTranslation, None),
156        LlmClientError::InvalidRequest { .. } => (RouteErrorKind::InvalidRequest, None),
157        LlmClientError::Configuration { .. } => (RouteErrorKind::Configuration, None),
158        LlmClientError::Ffi { .. } | LlmClientError::General(_) => (RouteErrorKind::Other, None),
159        _ => (RouteErrorKind::Other, None),
160    };
161    summary(kind, phase, upstream_status, target)
162}
163
164fn summary(
165    kind: RouteErrorKind,
166    phase: RouteErrorPhase,
167    upstream_status: Option<u16>,
168    target: Option<&ModelId>,
169) -> RouteErrorSummary {
170    RouteErrorSummary {
171        kind,
172        phase,
173        upstream_status,
174        target: target.cloned(),
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    const SECRET: &str = "patient name is Jane Doe";
183
184    #[test]
185    fn execution_error_summary_keeps_http_status_and_target_without_body() {
186        let error = RunnerError::Algorithm(LibsyError::ClientCall {
187            target: ModelId::from("strong"),
188            source: LlmClientError::UpstreamHttp {
189                status: reqwest::StatusCode::SERVICE_UNAVAILABLE,
190                body: format!("upstream response: {SECRET}"),
191            },
192        });
193
194        let summary = error.execution_error_summary();
195
196        assert!(matches!(summary.kind, RouteErrorKind::UpstreamHttp));
197        assert!(matches!(summary.phase, RouteErrorPhase::BeforeResponse));
198        assert_eq!(summary.upstream_status, Some(503));
199        assert_eq!(summary.target.as_ref().map(ModelId::as_str), Some("strong"));
200        assert!(!format!("{summary:?}").contains(SECRET));
201    }
202
203    #[test]
204    fn execution_error_summary_reduces_untrusted_messages_to_kinds() {
205        let error = RunnerError::Algorithm(LibsyError::AlgorithmError {
206            message: SECRET.to_string(),
207        });
208
209        let summary = error.execution_error_summary();
210
211        assert!(matches!(summary.kind, RouteErrorKind::Algorithm));
212        assert_eq!(summary.target, None);
213        assert!(!format!("{summary:?}").contains(SECRET));
214    }
215
216    #[test]
217    fn stream_error_summary_preserves_served_target_without_source_text() {
218        let error = LlmClientError::Timeout {
219            source: std::io::Error::other(SECRET).into(),
220        };
221
222        let summary = stream_error_summary(&error, Some(&ModelId::from("fallback")));
223
224        assert!(matches!(summary.kind, RouteErrorKind::Timeout));
225        assert!(matches!(summary.phase, RouteErrorPhase::DuringStream));
226        assert_eq!(
227            summary.target.as_ref().map(ModelId::as_str),
228            Some("fallback")
229        );
230        assert!(!format!("{summary:?}").contains(SECRET));
231    }
232
233    #[test]
234    fn stream_error_summary_uses_context_window_model_without_a_served_target() {
235        let error = LlmClientError::ContextWindowExceeded {
236            model: ModelId::from("weak"),
237            message: SECRET.to_string(),
238        };
239
240        let summary = stream_error_summary(&error, None);
241
242        assert!(matches!(
243            summary.kind,
244            RouteErrorKind::ContextWindowExceeded
245        ));
246        assert!(matches!(summary.phase, RouteErrorPhase::DuringStream));
247        assert_eq!(summary.target.as_ref().map(ModelId::as_str), Some("weak"));
248        assert!(!format!("{summary:?}").contains(SECRET));
249    }
250
251    #[test]
252    fn client_error_kinds_have_stable_telemetry_values() {
253        let cases = vec![
254            (
255                LlmClientError::ContextWindowExceeded {
256                    model: ModelId::from("weak"),
257                    message: SECRET.to_string(),
258                },
259                "context_window_exceeded",
260            ),
261            (
262                LlmClientError::Transport {
263                    source: std::io::Error::other(SECRET).into(),
264                },
265                "transport",
266            ),
267            (
268                LlmClientError::InvalidResponse {
269                    source: std::io::Error::other(SECRET).into(),
270                },
271                "invalid_response",
272            ),
273            (
274                LlmClientError::RequestTranslation(SECRET.to_string()),
275                "request_translation",
276            ),
277            (
278                LlmClientError::RequestEncoding(SECRET.to_string()),
279                "request_encoding",
280            ),
281            (
282                LlmClientError::ResponseTranslation(SECRET.to_string()),
283                "response_translation",
284            ),
285            (
286                LlmClientError::Configuration {
287                    message: SECRET.to_string(),
288                },
289                "configuration",
290            ),
291            (
292                LlmClientError::InvalidRequest {
293                    message: SECRET.to_string(),
294                },
295                "invalid_request",
296            ),
297            (LlmClientError::General(SECRET.to_string()), "other"),
298        ];
299
300        for (error, value) in cases {
301            let summary = stream_error_summary(&error, None);
302            assert_eq!(summary.kind.as_str(), value);
303            assert!(!format!("{summary:?}").contains(SECRET));
304        }
305    }
306
307    #[test]
308    fn route_error_phases_have_stable_telemetry_values() {
309        assert_eq!(RouteErrorPhase::BeforeResponse.as_str(), "before_response");
310        assert_eq!(RouteErrorPhase::DuringStream.as_str(), "during_stream");
311    }
312
313    #[test]
314    fn direct_client_errors_do_not_claim_a_target() {
315        let error = RunnerError::Client(LlmClientError::Timeout {
316            source: std::io::Error::other(SECRET).into(),
317        });
318
319        let summary = error.execution_error_summary();
320
321        assert!(matches!(summary.kind, RouteErrorKind::Timeout));
322        assert!(matches!(summary.phase, RouteErrorPhase::BeforeResponse));
323        assert_eq!(summary.target, None);
324        assert!(!format!("{summary:?}").contains(SECRET));
325    }
326
327    #[test]
328    fn runner_request_and_configuration_errors_are_classified() {
329        let configuration = RunnerError::configuration(SECRET);
330        assert!(matches!(
331            configuration.execution_error_summary().kind,
332            RouteErrorKind::Configuration
333        ));
334
335        let unsupported = RunnerError::AuxiliaryUnsupported;
336        assert!(matches!(
337            unsupported.execution_error_summary().kind,
338            RouteErrorKind::InvalidRequest
339        ));
340    }
341}