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}