1use serde::{Deserialize, Serialize};
12
13use super::code::AxCode;
14use super::provider::{AccountDisposition, 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 = "AccountDisposition::is_keep", default)]
46 account: AccountDisposition,
47 #[serde(skip_serializing_if = "Option::is_none", default)]
48 gate: Option<GateRefusal>,
49 #[serde(skip_serializing_if = "Option::is_none", default)]
50 provider: Option<ProviderFailureKind>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
58#[serde(rename_all = "lowercase")]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60pub enum Retry {
61 Yes,
63 No,
65 Unknown,
69}
70
71impl<'de> Deserialize<'de> for Retry {
72 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
73 #[derive(Deserialize)]
74 #[serde(untagged)]
75 enum Spelling {
76 Word(String),
77 Flag(bool),
78 }
79 match Spelling::deserialize(deserializer)? {
80 Spelling::Flag(true) => Ok(Retry::Yes),
81 Spelling::Flag(false) => Ok(Retry::No),
82 Spelling::Word(word) => match word.as_str() {
83 "yes" => Ok(Retry::Yes),
84 "no" => Ok(Retry::No),
85 "unknown" => Ok(Retry::Unknown),
86 other => Err(serde::de::Error::unknown_variant(
87 other,
88 &["yes", "no", "unknown"],
89 )),
90 },
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
102#[must_use = "an ErrorDraft becomes an AxError only through with_recovery"]
103pub struct ErrorDraft {
104 pending: AxError,
105}
106
107impl AxError {
108 pub fn failure(
111 code: AxCode,
112 action: impl Into<String>,
113 subject: impl Into<String>,
114 ) -> ErrorDraft {
115 ErrorDraft {
116 pending: AxError {
117 code,
118 detail: Box::new(ErrorDetail {
119 action: action.into(),
120 subject: subject.into(),
121 nearby: Vec::new(),
122 recovery: String::new(),
123 retry: Retry::No,
124 retry_after_ms: None,
125 account: AccountDisposition::Keep,
126 gate: None,
127 provider: None,
128 }),
129 },
130 }
131 }
132
133 pub fn refusal(
137 code: AxCode,
138 action: impl Into<String>,
139 subject: impl Into<String>,
140 gate: GateRefusal,
141 ) -> ErrorDraft {
142 let mut draft = AxError::failure(code, action, subject);
143 draft.pending.detail.gate = Some(gate);
144 draft
145 }
146
147 pub fn provider(
152 kind: ProviderFailureKind,
153 action: impl Into<String>,
154 subject: impl Into<String>,
155 ) -> ErrorDraft {
156 let mut draft = AxError::failure(AxCode::Provider, action, subject);
157 draft.pending.detail.provider = Some(kind);
158 draft
159 }
160
161 pub fn code(&self) -> &AxCode {
162 &self.code
163 }
164
165 pub fn action(&self) -> &str {
166 &self.detail.action
167 }
168
169 pub fn subject(&self) -> &str {
170 &self.detail.subject
171 }
172
173 pub fn nearby(&self) -> &[String] {
174 &self.detail.nearby
175 }
176
177 pub fn recovery(&self) -> &str {
178 &self.detail.recovery
179 }
180
181 pub fn retry(&self) -> Retry {
182 self.detail.retry
183 }
184
185 pub fn retry_after_ms(&self) -> Option<u64> {
188 self.detail.retry_after_ms
189 }
190
191 pub fn account(&self) -> AccountDisposition {
194 self.detail.account
195 }
196
197 pub fn gate(&self) -> Option<&GateRefusal> {
198 self.detail.gate.as_ref()
199 }
200
201 pub fn provider_failure(&self) -> Option<ProviderFailureKind> {
202 self.detail.provider
203 }
204
205 #[must_use]
210 pub fn rewrite_recovery(mut self, recovery: impl Into<String>) -> Self {
211 self.detail.recovery = recovery.into();
212 self
213 }
214}
215
216impl ErrorDraft {
217 pub fn with_nearby(mut self, nearby: Vec<String>) -> Self {
220 self.pending.detail.nearby = nearby;
221 self
222 }
223
224 pub fn retriable(mut self) -> Self {
228 self.pending.detail.retry = Retry::Yes;
229 self.pending.detail.account = AccountDisposition::Keep;
230 self
231 }
232
233 pub fn effect_unknown(mut self) -> Self {
238 self.pending.detail.retry = Retry::Unknown;
239 self.pending.detail.retry_after_ms = None;
240 self.pending.detail.account = AccountDisposition::Keep;
241 self
242 }
243
244 pub fn account_unusable(mut self) -> Self {
249 self.pending.detail.retry = Retry::No;
250 self.pending.detail.retry_after_ms = None;
251 self.pending.detail.account = AccountDisposition::Advance;
252 self
253 }
254
255 pub fn retriable_after(mut self, wait_ms: u64) -> Self {
258 self.pending.detail.retry_after_ms = Some(wait_ms);
259 self.retriable()
260 }
261
262 pub fn with_recovery(mut self, recovery: impl Into<String>) -> AxError {
266 self.pending.detail.recovery = recovery.into();
267 self.pending
268 }
269}
270
271#[cfg(test)]
272#[allow(
273 clippy::unwrap_used,
274 clippy::expect_used,
275 clippy::panic,
276 clippy::indexing_slicing,
277 reason = "test code"
278)]
279mod tests {
280 use super::*;
281 #[test]
282 fn failure_has_no_gate_and_is_not_retriable_by_default() {
283 let err = AxError::failure(AxCode::PathNotFound, "read file", "docs/a.md")
284 .with_recovery("create docs/a.md, or read one of the paths `ls docs` lists");
285 assert!(err.gate().is_none());
286 assert_eq!(err.retry(), Retry::No);
287 assert_eq!(err.code(), &AxCode::PathNotFound);
288 }
289
290 #[test]
291 fn builders_extend_without_touching_the_rest() {
292 let err = AxError::failure(AxCode::ToolUnknown, "call tool", "grep")
293 .with_nearby(vec!["exec".into(), "edit".into(), "status".into()])
294 .retriable()
295 .with_recovery("use an L0 tool");
296 assert_eq!(err.retry(), Retry::Yes);
297 let json = serde_json::to_value(&err).unwrap();
298 assert_eq!(json["code"], "E_TOOL_UNKNOWN");
299 assert_eq!(json["nearby"][0], "exec");
300 assert_eq!(json["recovery"], "use an L0 tool");
301 assert_eq!(json.get("gate"), None);
302 }
303
304 #[test]
308 fn a_stated_wait_reads_back_until_the_effect_is_declared_unknown() {
309 let draft = AxError::failure(AxCode::Provider, "call model", "429").retriable_after(1500);
310 let waiting = draft.with_recovery("wait, then send the request again");
311 assert_eq!(
312 (waiting.retry(), waiting.retry_after_ms()),
313 (Retry::Yes, Some(1500))
314 );
315 let unknown = AxError::failure(AxCode::Provider, "call model", "cut")
316 .retriable_after(1500)
317 .effect_unknown()
318 .with_recovery("read the ledger before sending it again");
319 assert_eq!(
320 (unknown.retry(), unknown.retry_after_ms()),
321 (Retry::Unknown, None)
322 );
323 }
324
325 #[test]
328 fn only_a_provider_error_names_a_provider_failure() {
329 let kind = ProviderFailureKind::Refused { status: 503 };
330 let named = AxError::provider(kind, "call model", "503").with_recovery("try later");
331 assert_eq!(
332 (named.code(), named.provider_failure()),
333 (&AxCode::Provider, Some(kind))
334 );
335 let plain = AxError::failure(AxCode::Provider, "call model", "503").with_recovery("r");
336 assert_eq!(plain.provider_failure(), None);
337 }
338
339 #[derive(Debug, Clone)]
346 enum Builder {
347 Retriable,
348 RetriableAfter(u64),
349 EffectUnknown,
350 AccountUnusable,
351 }
352
353 fn builder() -> impl proptest::strategy::Strategy<Value = Builder> {
354 use proptest::prelude::*;
355 prop_oneof![
356 Just(Builder::Retriable),
357 any::<u64>().prop_map(Builder::RetriableAfter),
358 Just(Builder::EffectUnknown),
359 Just(Builder::AccountUnusable),
360 ]
361 }
362
363 proptest::proptest! {
364 #[test]
365 fn no_order_of_calls_advances_a_resendable_failure(
366 steps in proptest::collection::vec(builder(), 0..12)
367 ) {
368 let draft = steps.into_iter().fold(
369 AxError::failure(AxCode::Provider, "call model", "s"),
370 |draft, step| match step {
371 Builder::Retriable => draft.retriable(),
372 Builder::RetriableAfter(wait_ms) => draft.retriable_after(wait_ms),
373 Builder::EffectUnknown => draft.effect_unknown(),
374 Builder::AccountUnusable => draft.account_unusable(),
375 },
376 );
377 let err = draft.with_recovery("r");
378 if err.account() == AccountDisposition::Advance {
379 proptest::prop_assert_eq!((err.retry(), err.retry_after_ms()), (Retry::No, None));
380 }
381 if err.retry_after_ms().is_some() {
382 proptest::prop_assert_eq!(err.retry(), Retry::Yes);
383 }
384 }
385 }
386
387 #[test]
391 fn only_a_switch_is_written_and_an_old_record_reads_as_keep() {
392 let kept = AxError::failure(AxCode::Provider, "call model", "s").with_recovery("r");
393 let advanced = AxError::failure(AxCode::Provider, "call model", "s")
394 .retriable_after(500)
395 .account_unusable()
396 .with_recovery("r");
397 let old = serde_json::json!({"code": "E_PROVIDER", "action": "call model",
398 "subject": "s", "nearby": [], "recovery": "r", "retry": "no"});
399 assert_eq!(
400 (
401 serde_json::to_value(&kept).unwrap(),
402 serde_json::to_value(&advanced).unwrap()["account"].clone(),
403 serde_json::from_value::<AxError>(old.clone())
404 .unwrap()
405 .account(),
406 ),
407 (old, serde_json::json!("advance"), AccountDisposition::Keep)
408 );
409 assert_eq!(
410 serde_json::from_value::<AxError>(serde_json::to_value(&advanced).unwrap()).unwrap(),
411 advanced
412 );
413 }
414
415 #[test]
416 fn axerror_serde_field_order_is_declaration_order() {
417 let err = AxError::failure(AxCode::Timeout, "run exec", "build.sh").with_recovery(
418 "raise `[tool] exec_timeout_ms` in CONFIG.toml, or run a shorter script",
419 );
420 let json = serde_json::to_string(&err).unwrap();
421 let code_at = json.find("\"code\"").unwrap();
422 let action_at = json.find("\"action\"").unwrap();
423 let subject_at = json.find("\"subject\"").unwrap();
424 let retriable_at = json.find("\"retry\"").unwrap();
425 assert!(code_at < action_at && action_at < subject_at && subject_at < retriable_at);
426 }
427
428 #[test]
429 fn a_ledger_record_written_with_the_retriable_flag_still_reads() {
430 let record = |flag: bool| {
431 serde_json::json!({"code": "E_PROVIDER", "action": "call model", "subject": "s",
432 "nearby": [], "recovery": "r", "retriable": flag})
433 };
434 let retry = |flag| {
435 serde_json::from_value::<AxError>(record(flag))
436 .unwrap()
437 .retry()
438 };
439 assert_eq!([retry(true), retry(false)], [Retry::Yes, Retry::No]);
440 }
441
442 #[test]
443 fn an_unknown_effect_carries_no_wait_whatever_the_builder_order() {
444 let err = AxError::failure(AxCode::Provider, "call model", "s")
445 .retriable_after(1_500)
446 .effect_unknown()
447 .with_recovery("r");
448 assert_eq!((err.retry(), err.retry_after_ms()), (Retry::Unknown, None));
449 }
450
451 #[test]
452 fn display_names_code_action_subject() {
453 let err = AxError::failure(AxCode::BudgetExhausted, "call model", "run-1")
454 .with_recovery("raise `[budget]` in CONFIG.toml, then resume run-1");
455 let text = err.to_string();
456 assert!(text.contains("E_BUDGET_EXHAUSTED"));
457 assert!(text.contains("call model"));
458 assert!(text.contains("run-1"));
459 }
460
461 #[test]
462 fn refusal_carries_the_three_mandatory_parts() {
463 let gate = GateRefusal::new("rule", "violation", "alternative");
464 let err = AxError::refusal(AxCode::GateDenied, "wire funds", "acct-9", gate)
465 .with_recovery("alternative");
466 let got = err.gate().expect("gate refusal must carry the three parts");
467 assert_eq!(got.rule(), "rule");
468 assert_eq!(got.violation(), "violation");
469 assert_eq!(got.alternative(), "alternative");
470 }
471
472 #[test]
473 fn every_constructed_error_carries_a_recovery_sentence() {
474 let failure = AxError::failure(AxCode::InvalidArgs, "parse address", "city//")
475 .with_recovery("write `city/<building>/<room>`");
476 let refusal = AxError::refusal(
477 AxCode::GateDenied,
478 "write file",
479 "/etc/hosts",
480 GateRefusal::new("rule", "violation", "alternative"),
481 )
482 .with_recovery("write inside the run's write domain");
483 assert!(!failure.recovery().is_empty());
484 assert!(!refusal.recovery().is_empty());
485 }
486}