Skip to main content

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;