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}