nemo_relay/error.rs
1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Error types for the NeMo Relay runtime.
5//!
6//! All fallible operations in the runtime return [`Result<T>`], which uses
7//! [`FlowError`] as the error type. Errors are categorized by cause
8//! (duplicate registration, missing entity, guardrail rejection, etc.).
9
10use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15/// Stable classification for a failure from an upstream provider attempt.
16#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum UpstreamFailureClass {
19 /// Provider connection could not be established or was interrupted.
20 Connection,
21 /// Provider request timed out.
22 Timeout,
23 /// Retryable HTTP status without a more specific provider classification.
24 RetryableStatus,
25 /// Provider rejected the request because its context window was exceeded.
26 ContextWindow,
27 /// Requested provider model is temporarily unavailable.
28 ModelUnavailable,
29 /// Provider authentication or authorization failed.
30 Authentication,
31 /// Provider rejected an invalid request.
32 InvalidRequest,
33 /// Other non-retryable provider failure.
34 Other,
35}
36
37/// Structured failure returned by one upstream provider attempt.
38#[derive(Clone, Debug, Deserialize, Serialize)]
39pub struct UpstreamFailure {
40 /// HTTP status when a provider response was received.
41 pub status: Option<u16>,
42 /// Bounded response body or transport error message.
43 pub body: String,
44 /// Safe response headers captured from the provider.
45 pub headers: BTreeMap<String, String>,
46 /// Retry classification.
47 pub class: UpstreamFailureClass,
48}
49
50impl UpstreamFailure {
51 /// Whether Switchyard may be consulted for another bounded provider attempt.
52 pub fn is_retryable(&self) -> bool {
53 matches!(
54 self.class,
55 UpstreamFailureClass::Connection
56 | UpstreamFailureClass::Timeout
57 | UpstreamFailureClass::RetryableStatus
58 | UpstreamFailureClass::ContextWindow
59 | UpstreamFailureClass::ModelUnavailable
60 )
61 }
62}
63
64impl std::fmt::Display for UpstreamFailure {
65 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self.status {
67 Some(status) => write!(
68 formatter,
69 "upstream provider returned HTTP {status} ({:?}): {}",
70 self.class, self.body
71 ),
72 None => write!(
73 formatter,
74 "upstream provider transport failure ({:?}): {}",
75 self.class, self.body
76 ),
77 }
78 }
79}
80
81/// The error type for all NeMo Relay runtime operations.
82///
83/// Each variant represents a distinct failure mode that callers can match on
84/// to determine the appropriate recovery strategy.
85#[derive(Clone, Debug, Error)]
86pub enum FlowError {
87 /// A resource with the given name is already registered.
88 ///
89 /// Returned when attempting to register a guardrail, intercept, or subscriber
90 /// with a name that is already in use. Deregister the existing entry first,
91 /// or choose a different name.
92 #[error("already exists: {0}")]
93 AlreadyExists(String),
94
95 /// The requested resource was not found.
96 ///
97 /// Returned when attempting to remove a scope handle by UUID that does not
98 /// exist in the scope stack, or when looking up a non-existent entity.
99 #[error("not found: {0}")]
100 NotFound(String),
101
102 /// A function argument was invalid for the requested operation.
103 ///
104 /// Returned when a provided value is well-formed but violates an API
105 /// precondition, such as attempting to pop a scope that is not currently
106 /// at the top of the stack.
107 #[error("invalid argument: {0}")]
108 InvalidArgument(String),
109
110 /// The scope stack is empty.
111 ///
112 /// This should not occur under normal operation because the root scope is
113 /// always present and cannot be removed.
114 #[error("scope stack empty")]
115 ScopeStackEmpty,
116
117 /// A conditional execution guardrail rejected the operation.
118 ///
119 /// The contained string is the rejection reason provided by the guardrail.
120 /// This is returned during `tool_call_execute` or `llm_call_execute` when
121 /// a conditional guardrail returns `Some(reason)`.
122 #[error("guardrail rejected: {0}")]
123 GuardrailRejected(String),
124
125 /// Structured upstream provider failure from retry-aware gateway dispatch.
126 #[error("{0}")]
127 Upstream(UpstreamFailure),
128
129 /// An internal runtime error (e.g., lock poisoning).
130 #[error("internal error: {0}")]
131 Internal(String),
132
133 /// An exception raised by a language-binding callback.
134 #[error("internal error: {message}")]
135 CallbackException {
136 /// Original binding-rendered exception message.
137 message: String,
138 /// Original language exception class name.
139 exception_type: String,
140 },
141}
142
143/// A specialized [`Result`](std::result::Result) type for NeMo Relay operations.
144pub type Result<T> = std::result::Result<T, FlowError>;
145
146impl FlowError {
147 /// Returns a low-cardinality classification suitable for OpenTelemetry's
148 /// `error.type` attribute.
149 ///
150 /// Relay-owned failures use stable `snake_case` codes. Internal failures
151 /// collapse to `internal_error` because Relay cannot reliably infer an
152 /// application exception type from an error message.
153 pub(crate) fn otel_error_type(&self) -> &str {
154 match self {
155 Self::AlreadyExists(_) => "already_exists",
156 Self::NotFound(_) => "not_found",
157 Self::InvalidArgument(_) => "invalid_argument",
158 Self::ScopeStackEmpty => "scope_stack_empty",
159 Self::GuardrailRejected(_) => "guardrail_rejected",
160 Self::Upstream(failure) => match failure.class {
161 UpstreamFailureClass::Connection => "connection_error",
162 UpstreamFailureClass::Timeout => "timeout",
163 UpstreamFailureClass::RetryableStatus => "retryable_status",
164 UpstreamFailureClass::ContextWindow => "context_window",
165 UpstreamFailureClass::ModelUnavailable => "model_unavailable",
166 UpstreamFailureClass::Authentication => "authentication",
167 UpstreamFailureClass::InvalidRequest => "invalid_request",
168 UpstreamFailureClass::Other => "upstream_error",
169 },
170 Self::Internal(_) | Self::CallbackException { .. } => "internal_error",
171 }
172 }
173
174 /// Returns the originating language exception class, when available.
175 pub(crate) fn exception_type(&self) -> Option<&str> {
176 match self {
177 Self::CallbackException { exception_type, .. } => Some(exception_type),
178 _ => None,
179 }
180 }
181}
182
183#[cfg(test)]
184#[path = "../tests/coverage/error_tests.rs"]
185mod tests;