Skip to main content

agent_framework_core/
error.rs

1//! Error types for the agent framework.
2
3use std::fmt;
4
5/// The result type used throughout the framework.
6pub type Result<T> = std::result::Result<T, Error>;
7
8/// The primary error type for the agent framework.
9///
10/// This mirrors the exception hierarchy used by the Python
11/// `agent_framework.exceptions` module while remaining idiomatic Rust.
12#[derive(Debug, thiserror::Error)]
13#[non_exhaustive]
14pub enum Error {
15    /// An error occurred while initializing an agent.
16    #[error("agent initialization error: {0}")]
17    AgentInitialization(String),
18
19    /// An error occurred while executing an agent run.
20    #[error("agent execution error: {0}")]
21    AgentExecution(String),
22
23    /// An error occurred while (de)serializing a value.
24    #[error("serialization error: {0}")]
25    Serialization(String),
26
27    /// A content item could not be parsed or was of an unknown type.
28    #[error("content error: {0}")]
29    Content(String),
30
31    /// A tool/function invocation failed.
32    #[error("tool error: {0}")]
33    Tool(String),
34
35    /// A chat client / service returned an error.
36    ///
37    /// Used for non-HTTP service failures (transport errors, stream-decode
38    /// errors, in-body error payloads on an otherwise-successful response). For
39    /// a non-success HTTP status, prefer [`Error::ServiceStatus`], which also
40    /// carries the status code and any `Retry-After`.
41    #[error("service error: {0}")]
42    Service(String),
43
44    /// A chat client / service returned a non-success HTTP status.
45    ///
46    /// Distinct from [`Error::Service`] so a retry layer can inspect the
47    /// numeric status code and any server-advised `Retry-After` delay (in
48    /// seconds). Displays like [`Error::Service`] (a `service error: ...`
49    /// message), with the status code folded into the message.
50    ///
51    /// This is the fallback classification for a non-success status that
52    /// isn't one of the more specific variants below (notably `408`/`429`/
53    /// `5xx`, which a retry layer treats as transient) — see
54    /// [`Error::ServiceInvalidAuth`], [`Error::ServiceInvalidRequest`], and
55    /// [`Error::ServiceContentFilter`] for statuses a provider client can
56    /// classify more precisely.
57    #[error("service error: {message}")]
58    ServiceStatus {
59        /// The HTTP status code returned by the service.
60        status: u16,
61        /// A human-readable message (typically the response body).
62        message: String,
63        /// The server-advised retry delay in seconds, parsed from the
64        /// `Retry-After` header when present.
65        retry_after: Option<f64>,
66    },
67
68    /// The service rejected the request due to missing or invalid
69    /// credentials (typically HTTP `401`/`403`).
70    ///
71    /// Mirrors upstream's `ServiceInvalidAuthError`. Like [`Error::Service`],
72    /// this carries no status code of its own (the numeric status, when
73    /// known, is folded into the message by the provider client) — it exists
74    /// so callers, and the default retry policy, can treat authentication /
75    /// authorization failures as definitively non-transient without
76    /// inspecting a status code themselves. Never retried by
77    /// [`RetryOn::Default`](crate::client::RetryOn::Default).
78    #[error("service error: {message}")]
79    ServiceInvalidAuth {
80        /// A human-readable message (typically the response body).
81        message: String,
82    },
83
84    /// The service rejected the request as malformed or otherwise invalid
85    /// (typically HTTP `400`/`404`/`422`) for a reason other than content
86    /// filtering.
87    ///
88    /// Mirrors upstream's `ServiceInvalidRequestError`. See
89    /// [`Error::ServiceContentFilter`] for the content-filter-specific case.
90    /// Never retried by [`RetryOn::Default`](crate::client::RetryOn::Default)
91    /// — a request that was rejected as invalid will be rejected again
92    /// unchanged.
93    #[error("service error: {message}")]
94    ServiceInvalidRequest {
95        /// A human-readable message (typically the response body).
96        message: String,
97    },
98
99    /// The service refused the request (or part of a response) because it
100    /// tripped a content filter / moderation policy.
101    ///
102    /// Mirrors upstream's `ServiceContentFilterException`
103    /// (`OpenAIContentFilterException` for OpenAI/Azure OpenAI specifically).
104    /// Never retried by [`RetryOn::Default`](crate::client::RetryOn::Default)
105    /// — the content, not the service, is the problem.
106    #[error("service error: {message}")]
107    ServiceContentFilter {
108        /// A human-readable message (typically the response body).
109        message: String,
110    },
111
112    /// Function middleware signalled an unrecoverable failure: the run must
113    /// stop rather than continue with a tool-error result.
114    ///
115    /// The function-invocation loop absorbs every other error a tool or its
116    /// middleware produces into a `FunctionResultContent { exception, .. }`,
117    /// hands it back to the model, and keeps looping — the right default for
118    /// an ordinary tool failure the model can recover from or route around.
119    /// An enforcement layer (a guardrail, a policy check, an authorization
120    /// gate) needs the opposite: when it refuses a call, the run must fail
121    /// closed, not hand the model an error string and let it try again.
122    ///
123    /// Middleware returning this variant gets that fail-closed escape. It is
124    /// the only error the loop propagates instead of absorbing; when one of a
125    /// parallel batch of calls raises it, the batch's in-flight siblings are
126    /// dropped (cancelled) and the failure surfaces from the run. Mirrors
127    /// upstream's `MiddlewareFailure` exception (Python #7562).
128    ///
129    /// The signal is carried by the error *type*, not by who produced it, so a
130    /// tool executor that returns this variant is propagated the same way.
131    /// Middleware that wants the ordinary absorb-and-continue contract should
132    /// keep returning any other variant — [`Error::Tool`] is the usual choice.
133    #[error("middleware failure: {0}")]
134    MiddlewareFailure(String),
135
136    /// A workflow validation or execution error.
137    #[error("workflow error: {0}")]
138    Workflow(String),
139
140    /// Two streamed content items could not be merged (mismatched ids).
141    #[error("addition item mismatch: {0}")]
142    AdditionItemMismatch(String),
143
144    /// A required configuration value was missing or invalid.
145    #[error("configuration error: {0}")]
146    Configuration(String),
147
148    /// An underlying JSON error.
149    #[error("json error: {0}")]
150    Json(#[from] serde_json::Error),
151
152    /// Any other error, wrapping a boxed source.
153    #[error("{0}")]
154    Other(String),
155}
156
157impl Error {
158    /// Create an [`Error::Other`] from anything displayable.
159    pub fn other(msg: impl fmt::Display) -> Self {
160        Error::Other(msg.to_string())
161    }
162
163    /// Create an [`Error::Service`] from anything displayable.
164    pub fn service(msg: impl fmt::Display) -> Self {
165        Error::Service(msg.to_string())
166    }
167
168    /// Create an [`Error::ServiceStatus`] from an HTTP status code, a message,
169    /// and an optional `Retry-After` delay (in seconds).
170    pub fn service_status(status: u16, msg: impl fmt::Display, retry_after: Option<f64>) -> Self {
171        Error::ServiceStatus {
172            status,
173            message: msg.to_string(),
174            retry_after,
175        }
176    }
177
178    /// Create an [`Error::ServiceInvalidAuth`] from anything displayable.
179    pub fn service_invalid_auth(msg: impl fmt::Display) -> Self {
180        Error::ServiceInvalidAuth {
181            message: msg.to_string(),
182        }
183    }
184
185    /// Create an [`Error::ServiceInvalidRequest`] from anything displayable.
186    pub fn service_invalid_request(msg: impl fmt::Display) -> Self {
187        Error::ServiceInvalidRequest {
188            message: msg.to_string(),
189        }
190    }
191
192    /// Create an [`Error::ServiceContentFilter`] from anything displayable.
193    pub fn service_content_filter(msg: impl fmt::Display) -> Self {
194        Error::ServiceContentFilter {
195            message: msg.to_string(),
196        }
197    }
198
199    /// The HTTP status code carried by this error, if it is an
200    /// [`Error::ServiceStatus`].
201    pub fn status(&self) -> Option<u16> {
202        match self {
203            Error::ServiceStatus { status, .. } => Some(*status),
204            _ => None,
205        }
206    }
207
208    /// The server-advised retry delay in seconds, if this is an
209    /// [`Error::ServiceStatus`] that carried a `Retry-After` header.
210    pub fn retry_after(&self) -> Option<f64> {
211        match self {
212            Error::ServiceStatus { retry_after, .. } => *retry_after,
213            _ => None,
214        }
215    }
216
217    /// Create an [`Error::Tool`] from anything displayable.
218    pub fn tool(msg: impl fmt::Display) -> Self {
219        Error::Tool(msg.to_string())
220    }
221
222    /// Create an [`Error::MiddlewareFailure`] from anything displayable: the
223    /// fail-closed signal function middleware returns to stop a run outright
224    /// instead of having its error absorbed into a tool-error result.
225    pub fn middleware_failure(msg: impl fmt::Display) -> Self {
226        Error::MiddlewareFailure(msg.to_string())
227    }
228
229    /// Whether this error is the [`Error::MiddlewareFailure`] fail-closed
230    /// signal, which the function-invocation loop propagates rather than
231    /// absorbing into a tool-error result.
232    pub fn is_middleware_failure(&self) -> bool {
233        matches!(self, Error::MiddlewareFailure(_))
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn service_invalid_auth_constructor_and_display() {
243        let err = Error::service_invalid_auth("OpenAI API error 401: unauthorized");
244        assert!(matches!(err, Error::ServiceInvalidAuth { .. }));
245        assert_eq!(
246            err.to_string(),
247            "service error: OpenAI API error 401: unauthorized"
248        );
249        // Not a `ServiceStatus`, so it carries no status/retry_after of its
250        // own (the numeric status lives in the message text instead).
251        assert_eq!(err.status(), None);
252        assert_eq!(err.retry_after(), None);
253    }
254
255    #[test]
256    fn service_invalid_request_constructor_and_display() {
257        let err = Error::service_invalid_request("OpenAI API error 400: bad request");
258        assert!(matches!(err, Error::ServiceInvalidRequest { .. }));
259        assert_eq!(
260            err.to_string(),
261            "service error: OpenAI API error 400: bad request"
262        );
263    }
264
265    #[test]
266    fn service_content_filter_constructor_and_display() {
267        let err = Error::service_content_filter("OpenAI API error 400: content filtered");
268        assert!(matches!(err, Error::ServiceContentFilter { .. }));
269        assert_eq!(
270            err.to_string(),
271            "service error: OpenAI API error 400: content filtered"
272        );
273    }
274
275    /// The new variants must not silently become retryable-looking:
276    /// `status()`/`retry_after()` only ever return `Some` for
277    /// [`Error::ServiceStatus`].
278    #[test]
279    fn new_variants_are_not_service_status() {
280        for err in [
281            Error::service_invalid_auth("x"),
282            Error::service_invalid_request("x"),
283            Error::service_content_filter("x"),
284        ] {
285            assert_eq!(err.status(), None, "{err:?}");
286            assert_eq!(err.retry_after(), None, "{err:?}");
287        }
288    }
289}