Skip to main content

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}