Skip to main content

axonflow_sdk_rust/
error.rs

1use crate::types::decisions::RateLimitEnvelope;
2use thiserror::Error;
3
4#[derive(Error, Debug)]
5pub enum AxonFlowError {
6    #[error("HTTP request failed: {0}")]
7    HttpError(#[from] reqwest::Error),
8    #[error("Serialization/Deserialization failed: {0}")]
9    SerdeError(#[from] serde_json::Error),
10    #[error("API error ({status}): {message}")]
11    ApiError { status: u16, message: String },
12    /// Tier-cap 429 with a parsed V1 upgrade envelope. Distinct from a
13    /// generic 429 ApiError because callers should branch on the upgrade
14    /// fields (tier / compare_url / buy_url) without re-parsing the
15    /// raw body. Mirrors the cross-SDK 429-with-envelope pattern
16    /// (#1982 / #1958). Boxed to keep `AxonFlowError` small —
17    /// `RateLimitEnvelope` is ~176 bytes and would dominate the enum
18    /// otherwise (clippy::result_large_err).
19    #[error("Rate limited (tier={}, limit_type={}): {}", .envelope.tier, .envelope.limit_type, .envelope.error)]
20    RateLimited { envelope: Box<RateLimitEnvelope> },
21    #[error("Configuration error: {0}")]
22    ConfigError(String),
23    #[error("AxonFlow platform is unavailable: {0}")]
24    Unavailable(String),
25    /// A Decision Mode `redact_pii` obligation could not be discharged through
26    /// the engine — it named no request-phase fulfillment, advertised a
27    /// content-type the PEP is not holding, named an endpoint this client will
28    /// not call, the engine call failed / returned non-200, or the engine
29    /// reported the redactor did not run (`redaction_evaluated=false`).
30    ///
31    /// This is the fail-closed signal of the PEP contract (ADR-056, #2563): the
32    /// caller MUST block, never forward the unredacted content. There is NO code
33    /// path in which the SDK redacts locally — fulfillment is always the engine
34    /// round-trip — so an obligation the engine cannot discharge fails closed
35    /// here rather than leaking PII.
36    #[error("Obligation not engine-fulfillable: {0}")]
37    ObligationNotFulfillable(String),
38}
39
40impl AxonFlowError {
41    pub fn is_retryable(&self) -> bool {
42        match self {
43            AxonFlowError::HttpError(e) => e.is_timeout() || e.is_connect(),
44            AxonFlowError::ApiError { status, .. } => *status >= 500 || *status == 429,
45            AxonFlowError::RateLimited { .. } => true,
46            AxonFlowError::Unavailable(_) => true,
47            _ => false,
48        }
49    }
50
51    /// Whether this error should trigger fail-open (return a synthetic success
52    /// response). Currently identical to [`is_retryable`]; maintained as a
53    /// separate method because future policy changes may diverge them (e.g.
54    /// `ConfigError` could be fail-open-eligible but not retryable).
55    pub fn is_fail_open_eligible(&self) -> bool {
56        self.is_retryable()
57    }
58}
59
60impl PartialEq for AxonFlowError {
61    fn eq(&self, other: &Self) -> bool {
62        match (self, other) {
63            (Self::HttpError(a), Self::HttpError(b)) => {
64                a.is_timeout() == b.is_timeout()
65                    && a.is_connect() == b.is_connect()
66                    && a.status() == b.status()
67                    && a.to_string() == b.to_string()
68            }
69            (Self::SerdeError(a), Self::SerdeError(b)) => a.to_string() == b.to_string(),
70            (
71                Self::ApiError {
72                    status: s1,
73                    message: m1,
74                },
75                Self::ApiError {
76                    status: s2,
77                    message: m2,
78                },
79            ) => s1 == s2 && m1 == m2,
80            (Self::RateLimited { envelope: e1 }, Self::RateLimited { envelope: e2 }) => e1 == e2,
81            (Self::ConfigError(m1), Self::ConfigError(m2)) => m1 == m2,
82            (Self::Unavailable(m1), Self::Unavailable(m2)) => m1 == m2,
83            _ => false,
84        }
85    }
86}