ferro_ai/error.rs
1/// Errors that can occur in ferro-ai operations.
2#[derive(Debug, thiserror::Error)]
3pub enum Error {
4 /// Configuration error (missing env var or invalid value).
5 #[error("ai config error: {0}")]
6 Config(String),
7
8 /// HTTP provider error with optional status code for retry logic.
9 ///
10 /// The `message` field carries only provider response text.
11 /// It MUST NOT contain the API key or auth header.
12 #[error("ai provider error ({status:?}): {message}")]
13 Provider {
14 /// HTTP status code if the failure carried one. Network errors have `None`.
15 status: Option<u16>,
16 /// Provider-supplied error message. Must not contain the API key or auth header.
17 message: String,
18 },
19
20 /// This provider does not implement the requested capability.
21 ///
22 /// Returned by capability methods a provider lacks (e.g. `AnthropicClient::embed()`).
23 /// Never panics — callers check for this variant to implement fallback behavior.
24 #[error("capability not supported by this provider")]
25 Unsupported,
26
27 /// Classification returned confidence below the configured threshold.
28 #[error("low confidence classification (confidence: {confidence:.2})")]
29 LowConfidence {
30 /// The best guess returned by the provider.
31 best_guess: serde_json::Value,
32 /// The confidence score (0.0–1.0).
33 confidence: f64,
34 },
35
36 /// Failed to deserialize the provider response into the target type.
37 #[error("deserialization error: {0}")]
38 Deserialization(String),
39
40 /// Schema normalization failed (malformed schemars output or unexpected structure).
41 #[error("schema normalization error: {0}")]
42 SchemaError(String),
43
44 /// Tool dispatch loop exceeded the configured `max_iterations` without finishing.
45 #[error("tool dispatch exceeded max_iterations ({0})")]
46 ToolIterationLimit(u32),
47
48 /// A tool name referenced in a provider response is not registered.
49 ///
50 /// **Not currently constructed by `ToolRegistry::dispatch`.** The dispatch loop
51 /// intentionally surfaces unknown tool names to the LLM as a `ToolError` message
52 /// (model-recoverable, per D-13/SC#6) rather than aborting the loop. This variant
53 /// is reserved for future direct-dispatch helpers (e.g. a `dispatch_single` that
54 /// calls one tool by name and must distinguish "not registered" from handler errors).
55 /// Callers pattern-matching on `Error` should not expect this variant to be
56 /// reachable from `dispatch` in the current implementation.
57 #[error("tool not found: {0}")]
58 ToolNotFound(String),
59
60 /// Request timed out after all retries were exhausted.
61 #[error("classification request timed out after retries")]
62 Timeout,
63
64 /// Confirmation store operation failed.
65 #[error("confirmation store error: {0}")]
66 StoreError(String),
67
68 /// sqlx database error from the `pgvector` store.
69 ///
70 /// Only reachable when the `pgvector` feature is enabled and
71 /// `PgVectorStore::store` or `PgVectorStore::nearest` is called.
72 /// The message is `sqlx::Error::to_string()` — it does not contain
73 /// embedding data (`f32` arrays are not included in sqlx error messages).
74 #[error("pgvector store error: {0}")]
75 Sqlx(String),
76}
77
78impl Error {
79 /// Returns `true` for errors that should trigger a retry.
80 ///
81 /// Permanent HTTP errors (400, 401, 403, 404, 422) are not retried.
82 /// Transient errors (429, 500, 503, 529) and network errors (`status: None`) are retried.
83 /// All non-`Provider` variants (including `Unsupported` and `Timeout`) return `false`.
84 pub fn is_retryable(&self) -> bool {
85 match self {
86 Error::Provider {
87 status: Some(s), ..
88 } => !matches!(s, 400 | 401 | 403 | 404 | 422),
89 Error::Provider { status: None, .. } => true, // network error → retry
90 _ => false,
91 }
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 #[test]
100 fn test_error_is_retryable() {
101 assert!(!Error::Provider {
102 status: Some(400),
103 message: "".into()
104 }
105 .is_retryable());
106 assert!(!Error::Provider {
107 status: Some(401),
108 message: "".into()
109 }
110 .is_retryable());
111 assert!(!Error::Provider {
112 status: Some(403),
113 message: "".into()
114 }
115 .is_retryable());
116 assert!(!Error::Provider {
117 status: Some(404),
118 message: "".into()
119 }
120 .is_retryable());
121 assert!(!Error::Provider {
122 status: Some(422),
123 message: "".into()
124 }
125 .is_retryable());
126 assert!(Error::Provider {
127 status: Some(429),
128 message: "".into()
129 }
130 .is_retryable());
131 assert!(Error::Provider {
132 status: Some(500),
133 message: "".into()
134 }
135 .is_retryable());
136 assert!(Error::Provider {
137 status: Some(503),
138 message: "".into()
139 }
140 .is_retryable());
141 assert!(Error::Provider {
142 status: Some(529),
143 message: "".into()
144 }
145 .is_retryable());
146 assert!(Error::Provider {
147 status: None,
148 message: "".into()
149 }
150 .is_retryable());
151 assert!(!Error::Unsupported.is_retryable());
152 assert!(!Error::Timeout.is_retryable());
153 }
154}