1use serde::{Deserialize, Serialize};
12
13use super::code::AxCode;
14use super::provider::ProviderFailureKind;
15use super::refusal::GateRefusal;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
26#[error("{code}: cannot {} on {}", detail.action, detail.subject)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28pub struct AxError {
29 code: AxCode,
30 #[serde(flatten)]
31 detail: Box<ErrorDetail>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
36struct ErrorDetail {
37 action: String,
38 subject: String,
39 nearby: Vec<String>,
40 recovery: String,
41 #[serde(alias = "retriable")]
42 retry: Retry,
43 #[serde(skip_serializing_if = "Option::is_none", default)]
44 retry_after_ms: Option<u64>,
45 #[serde(skip_serializing_if = "Option::is_none", default)]
46 gate: Option<GateRefusal>,
47 #[serde(skip_serializing_if = "Option::is_none", default)]
48 provider: Option<ProviderFailureKind>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
56#[serde(rename_all = "lowercase")]
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
58pub enum Retry {
59 Yes,
61 No,
63 Unknown,
67}
68
69impl<'de> Deserialize<'de> for Retry {
70 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
71 #[derive(Deserialize)]
72 #[serde(untagged)]
73 enum Spelling {
74 Word(String),
75 Flag(bool),
76 }
77 match Spelling::deserialize(deserializer)? {
78 Spelling::Flag(true) => Ok(Retry::Yes),
79 Spelling::Flag(false) => Ok(Retry::No),
80 Spelling::Word(word) => match word.as_str() {
81 "yes" => Ok(Retry::Yes),
82 "no" => Ok(Retry::No),
83 "unknown" => Ok(Retry::Unknown),
84 other => Err(serde::de::Error::unknown_variant(
85 other,
86 &["yes", "no", "unknown"],
87 )),
88 },
89 }
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
100#[must_use = "an ErrorDraft becomes an AxError only through with_recovery"]
101pub struct ErrorDraft {
102 pending: AxError,
103}
104
105impl AxError {
106 pub fn failure(
109 code: AxCode,
110 action: impl Into<String>,
111 subject: impl Into<String>,
112 ) -> ErrorDraft {
113 ErrorDraft {
114 pending: AxError {
115 code,
116 detail: Box::new(ErrorDetail {
117 action: action.into(),
118 subject: subject.into(),
119 nearby: Vec::new(),
120 recovery: String::new(),
121 retry: Retry::No,
122 retry_after_ms: None,
123 gate: None,
124 provider: None,
125 }),
126 },
127 }
128 }
129
130 pub fn refusal(
134 code: AxCode,
135 action: impl Into<String>,
136 subject: impl Into<String>,
137 gate: GateRefusal,
138 ) -> ErrorDraft {
139 let mut draft = AxError::failure(code, action, subject);
140 draft.pending.detail.gate = Some(gate);
141 draft
142 }
143
144 pub fn provider(
149 kind: ProviderFailureKind,
150 action: impl Into<String>,
151 subject: impl Into<String>,
152 ) -> ErrorDraft {
153 let mut draft = AxError::failure(AxCode::Provider, action, subject);
154 draft.pending.detail.provider = Some(kind);
155 draft
156 }
157
158 pub fn code(&self) -> &AxCode {
159 &self.code
160 }
161
162 pub fn action(&self) -> &str {
163 &self.detail.action
164 }
165
166 pub fn subject(&self) -> &str {
167 &self.detail.subject
168 }
169
170 pub fn nearby(&self) -> &[String] {
171 &self.detail.nearby
172 }
173
174 pub fn recovery(&self) -> &str {
175 &self.detail.recovery
176 }
177
178 pub fn retry(&self) -> Retry {
179 self.detail.retry
180 }
181
182 pub fn retry_after_ms(&self) -> Option<u64> {
185 self.detail.retry_after_ms
186 }
187
188 pub fn gate(&self) -> Option<&GateRefusal> {
189 self.detail.gate.as_ref()
190 }
191
192 pub fn provider_failure(&self) -> Option<ProviderFailureKind> {
193 self.detail.provider
194 }
195
196 #[must_use]
201 pub fn rewrite_recovery(mut self, recovery: impl Into<String>) -> Self {
202 self.detail.recovery = recovery.into();
203 self
204 }
205}
206
207impl ErrorDraft {
208 pub fn with_nearby(mut self, nearby: Vec<String>) -> Self {
211 self.pending.detail.nearby = nearby;
212 self
213 }
214
215 pub fn retriable(mut self) -> Self {
217 self.pending.detail.retry = Retry::Yes;
218 self
219 }
220
221 pub fn effect_unknown(mut self) -> Self {
225 self.pending.detail.retry = Retry::Unknown;
226 self.pending.detail.retry_after_ms = None;
227 self
228 }
229
230 pub fn retriable_after(mut self, wait_ms: u64) -> Self {
233 self.pending.detail.retry_after_ms = Some(wait_ms);
234 self.retriable()
235 }
236
237 pub fn with_recovery(mut self, recovery: impl Into<String>) -> AxError {
241 self.pending.detail.recovery = recovery.into();
242 self.pending
243 }
244}
245
246#[cfg(test)]
247#[allow(
248 clippy::unwrap_used,
249 clippy::expect_used,
250 clippy::panic,
251 clippy::indexing_slicing,
252 reason = "test code"
253)]
254mod tests {
255 use super::*;
256 #[test]
257 fn failure_has_no_gate_and_is_not_retriable_by_default() {
258 let err = AxError::failure(AxCode::PathNotFound, "read file", "docs/a.md")
259 .with_recovery("create docs/a.md, or read one of the paths `ls docs` lists");
260 assert!(err.gate().is_none());
261 assert_eq!(err.retry(), Retry::No);
262 assert_eq!(err.code(), &AxCode::PathNotFound);
263 }
264
265 #[test]
266 fn builders_extend_without_touching_the_rest() {
267 let err = AxError::failure(AxCode::ToolUnknown, "call tool", "grep")
268 .with_nearby(vec!["exec".into(), "edit".into(), "status".into()])
269 .retriable()
270 .with_recovery("use an L0 tool");
271 assert_eq!(err.retry(), Retry::Yes);
272 let json = serde_json::to_value(&err).unwrap();
273 assert_eq!(json["code"], "E_TOOL_UNKNOWN");
274 assert_eq!(json["nearby"][0], "exec");
275 assert_eq!(json["recovery"], "use an L0 tool");
276 assert_eq!(json.get("gate"), None);
277 }
278
279 #[test]
283 fn a_stated_wait_reads_back_until_the_effect_is_declared_unknown() {
284 let draft = AxError::failure(AxCode::Provider, "call model", "429").retriable_after(1500);
285 let waiting = draft.with_recovery("wait, then send the request again");
286 assert_eq!(
287 (waiting.retry(), waiting.retry_after_ms()),
288 (Retry::Yes, Some(1500))
289 );
290 let unknown = AxError::failure(AxCode::Provider, "call model", "cut")
291 .retriable_after(1500)
292 .effect_unknown()
293 .with_recovery("read the ledger before sending it again");
294 assert_eq!(
295 (unknown.retry(), unknown.retry_after_ms()),
296 (Retry::Unknown, None)
297 );
298 }
299
300 #[test]
303 fn only_a_provider_error_names_a_provider_failure() {
304 let kind = ProviderFailureKind::Refused { status: 503 };
305 let named = AxError::provider(kind, "call model", "503").with_recovery("try later");
306 assert_eq!(
307 (named.code(), named.provider_failure()),
308 (&AxCode::Provider, Some(kind))
309 );
310 let plain = AxError::failure(AxCode::Provider, "call model", "503").with_recovery("r");
311 assert_eq!(plain.provider_failure(), None);
312 }
313
314 #[test]
315 fn axerror_serde_field_order_is_declaration_order() {
316 let err = AxError::failure(AxCode::Timeout, "run exec", "build.sh").with_recovery(
317 "raise `[tool] exec_timeout_ms` in CONFIG.toml, or run a shorter script",
318 );
319 let json = serde_json::to_string(&err).unwrap();
320 let code_at = json.find("\"code\"").unwrap();
321 let action_at = json.find("\"action\"").unwrap();
322 let subject_at = json.find("\"subject\"").unwrap();
323 let retriable_at = json.find("\"retry\"").unwrap();
324 assert!(code_at < action_at && action_at < subject_at && subject_at < retriable_at);
325 }
326
327 #[test]
328 fn a_ledger_record_written_with_the_retriable_flag_still_reads() {
329 let record = |flag: bool| {
330 serde_json::json!({"code": "E_PROVIDER", "action": "call model", "subject": "s",
331 "nearby": [], "recovery": "r", "retriable": flag})
332 };
333 let retry = |flag| {
334 serde_json::from_value::<AxError>(record(flag))
335 .unwrap()
336 .retry()
337 };
338 assert_eq!([retry(true), retry(false)], [Retry::Yes, Retry::No]);
339 }
340
341 #[test]
342 fn an_unknown_effect_carries_no_wait_whatever_the_builder_order() {
343 let err = AxError::failure(AxCode::Provider, "call model", "s")
344 .retriable_after(1_500)
345 .effect_unknown()
346 .with_recovery("r");
347 assert_eq!((err.retry(), err.retry_after_ms()), (Retry::Unknown, None));
348 }
349
350 #[test]
351 fn display_names_code_action_subject() {
352 let err = AxError::failure(AxCode::BudgetExhausted, "call model", "run-1")
353 .with_recovery("raise `[budget]` in CONFIG.toml, then resume run-1");
354 let text = err.to_string();
355 assert!(text.contains("E_BUDGET_EXHAUSTED"));
356 assert!(text.contains("call model"));
357 assert!(text.contains("run-1"));
358 }
359
360 #[test]
361 fn refusal_carries_the_three_mandatory_parts() {
362 let gate = GateRefusal::new("rule", "violation", "alternative");
363 let err = AxError::refusal(AxCode::GateDenied, "wire funds", "acct-9", gate)
364 .with_recovery("alternative");
365 let got = err.gate().expect("gate refusal must carry the three parts");
366 assert_eq!(got.rule(), "rule");
367 assert_eq!(got.violation(), "violation");
368 assert_eq!(got.alternative(), "alternative");
369 }
370
371 #[test]
372 fn every_constructed_error_carries_a_recovery_sentence() {
373 let failure = AxError::failure(AxCode::InvalidArgs, "parse address", "city//")
374 .with_recovery("write `city/<building>/<room>`");
375 let refusal = AxError::refusal(
376 AxCode::GateDenied,
377 "write file",
378 "/etc/hosts",
379 GateRefusal::new("rule", "violation", "alternative"),
380 )
381 .with_recovery("write inside the run's write domain");
382 assert!(!failure.recovery().is_empty());
383 assert!(!refusal.recovery().is_empty());
384 }
385}