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}