Skip to main content

cow_sdk_core/
errors.rs

1use thiserror::Error;
2
3use crate::{cancellation::Cancelled, config::CowEnv, redaction::Redacted};
4
5/// Validation failures for typed user input and configuration values.
6#[non_exhaustive]
7#[derive(Debug, Clone, PartialEq, Eq, Error)]
8pub enum ValidationError {
9    /// A required string or collection field was empty after validation.
10    #[error("{field} must not be empty")]
11    EmptyField {
12        /// Identifies the invalid field.
13        field: &'static str,
14    },
15    /// A value could not be serialized into a valid HTTP header.
16    #[error("{field} must be a valid HTTP header value")]
17    InvalidHttpHeaderValue {
18        /// Identifies the invalid field.
19        field: &'static str,
20    },
21    /// A hexadecimal value did not include the required `0x` prefix.
22    #[error("{field} must be 0x-prefixed hexadecimal data")]
23    InvalidHexPrefix {
24        /// Identifies the invalid field.
25        field: &'static str,
26    },
27    /// A fixed-length hexadecimal value had the wrong number of hex characters.
28    #[error("{field} must contain exactly {expected} hex characters")]
29    InvalidHexLength {
30        /// Identifies the invalid field.
31        field: &'static str,
32        /// Required number of hex characters excluding the `0x` prefix.
33        expected: usize,
34    },
35    /// A hexadecimal value contained non-hex characters.
36    #[error("{field} contains non-hex characters")]
37    InvalidHexCharacters {
38        /// Identifies the invalid field.
39        field: &'static str,
40    },
41    /// A decimal or hexadecimal numeric value could not be parsed.
42    #[error("{field} must be a non-negative integer quantity")]
43    InvalidNumeric {
44        /// Identifies the invalid field.
45        field: &'static str,
46    },
47    /// A parsed numeric value exceeded the supported `uint256` range.
48    #[error("{field} exceeds uint256 bounds")]
49    NumericOverflow {
50        /// Identifies the invalid field.
51        field: &'static str,
52    },
53    /// A chain id was not part of the supported `CoW` Protocol network set.
54    #[error("unsupported chain id {chain_id}")]
55    UnsupportedChain {
56        /// Unsupported numeric chain id supplied by the caller.
57        chain_id: u64,
58    },
59    /// A `valid_to` timestamp exceeded the protocol-fixed `u32` epoch ceiling.
60    #[error("valid_to {actual_seconds} exceeds the protocol u32 epoch ceiling")]
61    ValidToOutOfRange {
62        /// Requested absolute timestamp in seconds.
63        actual_seconds: u64,
64    },
65    /// A decimals scale passed to [`Amount::parse_units`] was above the
66    /// maximum representable value.
67    ///
68    /// The maximum is `77` because `10^77 < 2^256 - 1 < 10^78`, so any
69    /// `decimals` value above `77` would make `10^decimals` overflow the
70    /// inner `uint256` that backs [`Amount`]. Every ERC-20 token across the
71    /// supported chains ships `decimals <= 18`, so the bound is structurally
72    /// satisfied in practice; the explicit error fails closed at
73    /// construction time instead of saturating or panicking.
74    ///
75    /// [`Amount`]: crate::Amount
76    /// [`Amount::parse_units`]: crate::Amount::parse_units
77    #[error("decimals scale {actual} exceeds the maximum representable value {max}")]
78    DecimalsOutOfRange {
79        /// The decimals scale that was rejected.
80        actual: u8,
81        /// The maximum representable decimals scale.
82        max: u8,
83    },
84}
85
86/// Top-level core crate error.
87#[non_exhaustive]
88#[derive(Debug, Clone, PartialEq, Eq, Error)]
89pub enum CoreError {
90    /// Validation failed for a typed user input or configuration value.
91    #[error("validation error: {0}")]
92    Validation(#[from] ValidationError),
93    /// The selected chain/environment pair did not resolve to a base URL.
94    #[error(
95        "missing API base URL for chain id {chain_id} in {env} environment (partner_api={partner_api})"
96    )]
97    MissingBaseUrl {
98        /// Numeric chain id that could not be resolved.
99        chain_id: u64,
100        /// Environment name used during resolution.
101        env: CowEnv,
102        /// Whether partner API URLs were being requested.
103        partner_api: bool,
104    },
105    /// A JSON or ABI-adjacent serialization step failed.
106    ///
107    /// Retained as a redaction-conformance witness: it is constructed only by
108    /// the error-redaction and classification test suites to prove this arm's
109    /// `Debug` redacts, and no production path emits it.
110    #[error("serialization error: {0}")]
111    Serialization(Redacted<String>),
112    /// A downstream transport implementation violated the core contract.
113    ///
114    /// Retained as a redaction-conformance witness: it is constructed only by
115    /// the error-redaction and classification test suites to prove this arm's
116    /// `Debug` redacts, and no production path emits it.
117    #[error("transport contract violation: {0}")]
118    TransportContract(Redacted<String>),
119    /// A long-running operation was cancelled through a cooperative cancellation token.
120    #[error("operation was cancelled")]
121    Cancelled,
122}
123
124impl From<Cancelled> for CoreError {
125    fn from(_: Cancelled) -> Self {
126        Self::Cancelled
127    }
128}
129
130/// Coarse-grained failure classification shared across the workspace error
131/// family.
132///
133/// Every public error type that the `cow-sdk` facade aggregates exposes a
134/// `class(&self) -> ErrorClass` accessor that resolves to one of these
135/// buckets, so downstream telemetry and retry layers can partition failures
136/// without pattern-matching every nested variant by hand.
137/// [`ErrorClass::Transport`] is the retryable class: the failure happened
138/// before a complete response arrived, so resending is safe. For
139/// [`ErrorClass::Remote`] the class alone is insufficient — a structured
140/// 4xx rejection is permanent while a 5xx outage is transient — so consult
141/// the concrete error's `is_retryable()` / `backoff_hint()` accessors
142/// before retrying. The remaining classes signal caller-side or
143/// protocol-level conditions that benefit from different recovery paths.
144#[non_exhaustive]
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
146pub enum ErrorClass {
147    /// Caller-side input failed a client-side validation boundary.
148    Validation,
149    /// A transport-layer failure occurred before a complete response was received.
150    Transport,
151    /// The remote endpoint returned a structured error response.
152    Remote,
153    /// The remote endpoint signalled rate limiting (HTTP 429) and the
154    /// transport layer's retry budget was exhausted before it cleared.
155    ///
156    /// Transport retries already honor `Retry-After`, so reaching this class
157    /// means the throttle outlived the retry policy rather than a transient
158    /// spike the client absorbed.
159    RateLimited,
160    /// A signing, provider, or cryptographic helper surfaced an error.
161    Signing,
162    /// A long-running operation was cancelled through a cooperative token.
163    Cancelled,
164    /// An internal invariant or helper contract was violated.
165    Internal,
166}
167
168impl ErrorClass {
169    /// Returns the stable lowercase telemetry label for this class.
170    ///
171    /// The label is a stability contract for telemetry partitioning, mirroring
172    /// [`TransportErrorClass::as_str`](crate::TransportErrorClass::as_str) and
173    /// the adapter class enums. Prefer it over `{:?}`, whose `Debug` output is
174    /// not contractual.
175    #[must_use]
176    pub const fn as_str(self) -> &'static str {
177        match self {
178            Self::Validation => "validation",
179            Self::Transport => "transport",
180            Self::Remote => "remote",
181            Self::RateLimited => "rate-limited",
182            Self::Signing => "signing",
183            Self::Cancelled => "cancelled",
184            Self::Internal => "internal",
185        }
186    }
187}
188
189impl core::fmt::Display for ErrorClass {
190    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
191        f.write_str(self.as_str())
192    }
193}
194
195/// Maps a `serde_json` failure to its stable category tag.
196///
197/// Returns `"io"`, `"syntax"`, `"data"`, or `"eof"` — the label names the failure
198/// class without echoing any decoded bytes, so it is safe to surface on a redacted
199/// error (ADR 0025). The orderbook, contracts, and app-data crates share this one
200/// classifier on their `From<serde_json::Error>` conversions.
201#[must_use]
202pub fn serialization_error_category(error: &serde_json::Error) -> &'static str {
203    match error.classify() {
204        serde_json::error::Category::Io => "io",
205        serde_json::error::Category::Syntax => "syntax",
206        serde_json::error::Category::Data => "data",
207        serde_json::error::Category::Eof => "eof",
208    }
209}
210
211impl CoreError {
212    /// Returns the coarse-grained [`ErrorClass`] for this error.
213    #[must_use]
214    pub const fn class(&self) -> ErrorClass {
215        match self {
216            Self::Validation(_) | Self::MissingBaseUrl { .. } => ErrorClass::Validation,
217            Self::Cancelled => ErrorClass::Cancelled,
218            // Serialization and transport-contract failures plus any future
219            // additive variants signal invariant violations.
220            _ => ErrorClass::Internal,
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::ErrorClass;
228
229    #[test]
230    fn error_class_labels_are_stable_and_display_matches_as_str() {
231        for (class, label) in [
232            (ErrorClass::Validation, "validation"),
233            (ErrorClass::Transport, "transport"),
234            (ErrorClass::Remote, "remote"),
235            (ErrorClass::RateLimited, "rate-limited"),
236            (ErrorClass::Signing, "signing"),
237            (ErrorClass::Cancelled, "cancelled"),
238            (ErrorClass::Internal, "internal"),
239        ] {
240            assert_eq!(class.as_str(), label);
241            assert_eq!(class.to_string(), label);
242        }
243    }
244}