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