agent_effects_store/failure.rs
1//! Classification of execution failures.
2
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6
7/// What kind of failure an action reported.
8///
9/// The classification, not the mere presence of an error, decides whether the
10/// runtime retries, fails or treats the outcome as unknown.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case", tag = "class")]
13pub enum FailureClass {
14 /// The request did not take effect and may succeed if repeated, e.g. a
15 /// connection refused before anything was sent.
16 Transient,
17 /// The request did not take effect and will not succeed if repeated.
18 Permanent,
19 /// The request may or may not have taken effect, e.g. a timeout after
20 /// the request was sent.
21 Ambiguous,
22 /// The remote system asked the caller to slow down.
23 RateLimited {
24 /// How long the remote system asked the caller to wait, if it said.
25 retry_after: Option<Duration>,
26 },
27 /// The credentials were missing or invalid.
28 Authentication,
29 /// The credentials were valid but not allowed to perform the action.
30 Authorization,
31 /// The remote system rejected the request as malformed.
32 Validation,
33}
34
35/// What the runtime does with a failure.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum Disposition {
38 /// The effect definitely did not apply; retry if the policy allows.
39 Retry,
40 /// The effect definitely did not apply; stop.
41 Fail,
42 /// The effect may have applied; the outcome is unknown.
43 Unknown,
44}
45
46impl FailureClass {
47 /// How the runtime treats this failure.
48 pub const fn disposition(self) -> Disposition {
49 match self {
50 Self::Transient | Self::RateLimited { .. } => Disposition::Retry,
51 Self::Ambiguous => Disposition::Unknown,
52 Self::Permanent | Self::Authentication | Self::Authorization | Self::Validation => {
53 Disposition::Fail
54 }
55 }
56 }
57
58 /// The minimum delay the remote system asked for, if any.
59 pub const fn retry_after(self) -> Option<Duration> {
60 match self {
61 Self::RateLimited { retry_after } => retry_after,
62 _ => None,
63 }
64 }
65
66 /// Refines a classification with whether the request reached the remote
67 /// system.
68 ///
69 /// Knowing the request was never sent (`Some(false)`, e.g. a refused
70 /// connection or a DNS failure) turns [`Self::Ambiguous`] into
71 /// [`Self::Transient`], because nothing can have been applied. Nothing
72 /// else changes: a sent request that got a definitive answer, such as a
73 /// 503, is still whatever its class says.
74 #[must_use]
75 pub const fn with_request_sent(self, request_sent: Option<bool>) -> Self {
76 match (self, request_sent) {
77 (Self::Ambiguous, Some(false)) => Self::Transient,
78 _ => self,
79 }
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86
87 #[test]
88 fn unsent_requests_are_never_ambiguous() {
89 assert_eq!(
90 FailureClass::Ambiguous.with_request_sent(Some(false)),
91 FailureClass::Transient
92 );
93 assert_eq!(
94 FailureClass::Ambiguous.with_request_sent(Some(true)),
95 FailureClass::Ambiguous
96 );
97 assert_eq!(
98 FailureClass::Ambiguous.with_request_sent(None),
99 FailureClass::Ambiguous
100 );
101 assert_eq!(
102 FailureClass::Transient.with_request_sent(Some(true)),
103 FailureClass::Transient
104 );
105 }
106
107 #[test]
108 fn dispositions() {
109 assert_eq!(FailureClass::Ambiguous.disposition(), Disposition::Unknown);
110 assert_eq!(
111 FailureClass::RateLimited { retry_after: None }.disposition(),
112 Disposition::Retry
113 );
114 assert_eq!(
115 FailureClass::Authentication.disposition(),
116 Disposition::Fail
117 );
118 }
119}