Skip to main content

confium_tc_core/
error_codes.rs

1//! Error code catalog — typed error codes for all coordinator errors.
2//!
3//! Provides a stable, programmatic error code for each error type.
4//! Clients can switch on error codes for automated error handling
5//! (retry, escalate, ignore) without parsing error messages.
6
7use serde::{Deserialize, Serialize};
8
9/// A stable error code identifying the error category.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum ErrorCode {
13    /// Session not found.
14    SessionNotFound,
15    /// Session in wrong state for the operation.
16    InvalidSessionState,
17    /// Threshold not met.
18    ThresholdNotMet,
19    /// Duplicate submission from a signer.
20    DuplicateSubmission,
21    /// Session unlock window expired.
22    SessionExpired,
23    /// Unauthorized signer.
24    UnauthorizedSigner,
25    /// Signing engine failure.
26    SigningFailed,
27    /// Rate limited.
28    RateLimited,
29    /// Backpressure: at capacity.
30    AtCapacity,
31    /// Backpressure timeout.
32    BackpressureTimeout,
33    /// Policy denied the request.
34    PolicyDenied,
35    /// Share integrity check failed.
36    InvalidShare,
37    /// Network error.
38    NetworkError,
39    /// Configuration error.
40    ConfigError,
41    /// Store (persistence) error.
42    StoreError,
43    /// Internal error.
44    Internal,
45}
46
47impl ErrorCode {
48    /// Numeric code for this error.
49    pub fn code(&self) -> u32 {
50        match self {
51            Self::SessionNotFound => 1001,
52            Self::InvalidSessionState => 1002,
53            Self::ThresholdNotMet => 1003,
54            Self::DuplicateSubmission => 1004,
55            Self::SessionExpired => 1005,
56            Self::UnauthorizedSigner => 1006,
57            Self::SigningFailed => 1007,
58            Self::RateLimited => 2001,
59            Self::AtCapacity => 2002,
60            Self::BackpressureTimeout => 2003,
61            Self::PolicyDenied => 3001,
62            Self::InvalidShare => 4001,
63            Self::NetworkError => 5001,
64            Self::ConfigError => 6001,
65            Self::StoreError => 7001,
66            Self::Internal => 9001,
67        }
68    }
69
70    /// Whether this error is retryable.
71    pub fn is_retryable(&self) -> bool {
72        matches!(
73            self,
74            Self::ThresholdNotMet | Self::NetworkError | Self::BackpressureTimeout
75        )
76    }
77
78    /// Whether this error indicates a client error (4xx equivalent).
79    pub fn is_client_error(&self) -> bool {
80        matches!(
81            self,
82            Self::SessionNotFound
83                | Self::InvalidSessionState
84                | Self::DuplicateSubmission
85                | Self::UnauthorizedSigner
86                | Self::PolicyDenied
87                | Self::InvalidShare
88        )
89    }
90
91    /// Human-readable description.
92    pub fn description(&self) -> &'static str {
93        match self {
94            Self::SessionNotFound => "The requested session does not exist.",
95            Self::InvalidSessionState => {
96                "The session is not in the required state for this operation."
97            }
98            Self::ThresholdNotMet => "Not enough shares have been submitted to meet the threshold.",
99            Self::DuplicateSubmission => "This signer has already submitted to this session.",
100            Self::SessionExpired => "The session's unlock window has elapsed.",
101            Self::UnauthorizedSigner => "This signer is not authorized for the quorum.",
102            Self::SigningFailed => "The threshold signing engine produced an error.",
103            Self::RateLimited => "Too many requests. Slow down and retry later.",
104            Self::AtCapacity => "The coordinator is at maximum session capacity.",
105            Self::BackpressureTimeout => "The operation timed out due to backpressure.",
106            Self::PolicyDenied => "A policy rule denied the request.",
107            Self::InvalidShare => "The submitted share failed integrity verification.",
108            Self::NetworkError => "A network I/O error occurred.",
109            Self::ConfigError => "Configuration is invalid or incomplete.",
110            Self::StoreError => "A persistence backend error occurred.",
111            Self::Internal => "An unexpected internal error occurred.",
112        }
113    }
114
115    /// Look up an error code by its numeric value.
116    pub fn from_code(code: u32) -> Option<Self> {
117        match code {
118            1001 => Some(Self::SessionNotFound),
119            1002 => Some(Self::InvalidSessionState),
120            1003 => Some(Self::ThresholdNotMet),
121            1004 => Some(Self::DuplicateSubmission),
122            1005 => Some(Self::SessionExpired),
123            1006 => Some(Self::UnauthorizedSigner),
124            1007 => Some(Self::SigningFailed),
125            2001 => Some(Self::RateLimited),
126            2002 => Some(Self::AtCapacity),
127            2003 => Some(Self::BackpressureTimeout),
128            3001 => Some(Self::PolicyDenied),
129            4001 => Some(Self::InvalidShare),
130            5001 => Some(Self::NetworkError),
131            6001 => Some(Self::ConfigError),
132            7001 => Some(Self::StoreError),
133            9001 => Some(Self::Internal),
134            _ => None,
135        }
136    }
137}
138
139impl std::fmt::Display for ErrorCode {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        write!(f, "{} ({})", self.code(), self.description())
142    }
143}
144
145/// A structured error response carrying an error code and message.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct TypedError {
148    /// The error code.
149    pub code: ErrorCode,
150    /// Numeric code (for convenience).
151    pub numeric_code: u32,
152    /// Human-readable message.
153    pub message: String,
154    /// Whether this error is retryable.
155    pub retryable: bool,
156}
157
158impl TypedError {
159    /// Create a new typed error.
160    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
161        Self {
162            code,
163            numeric_code: code.code(),
164            message: message.into(),
165            retryable: code.is_retryable(),
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn codes_are_unique() {
176        let codes = [
177            ErrorCode::SessionNotFound,
178            ErrorCode::InvalidSessionState,
179            ErrorCode::ThresholdNotMet,
180            ErrorCode::DuplicateSubmission,
181            ErrorCode::SessionExpired,
182            ErrorCode::UnauthorizedSigner,
183            ErrorCode::SigningFailed,
184            ErrorCode::RateLimited,
185            ErrorCode::AtCapacity,
186            ErrorCode::BackpressureTimeout,
187            ErrorCode::PolicyDenied,
188            ErrorCode::InvalidShare,
189            ErrorCode::NetworkError,
190            ErrorCode::ConfigError,
191            ErrorCode::StoreError,
192            ErrorCode::Internal,
193        ];
194        let mut seen = std::collections::HashSet::new();
195        for code in &codes {
196            assert!(seen.insert(code.code()), "duplicate code: {}", code.code());
197        }
198    }
199
200    #[test]
201    fn from_code_round_trips() {
202        for original in [
203            ErrorCode::SessionNotFound,
204            ErrorCode::ThresholdNotMet,
205            ErrorCode::RateLimited,
206            ErrorCode::Internal,
207        ] {
208            let recovered = ErrorCode::from_code(original.code()).unwrap();
209            assert_eq!(original, recovered);
210        }
211    }
212
213    #[test]
214    fn from_code_unknown_returns_none() {
215        assert!(ErrorCode::from_code(9999).is_none());
216    }
217
218    #[test]
219    fn retryable_codes_correct() {
220        assert!(ErrorCode::ThresholdNotMet.is_retryable());
221        assert!(ErrorCode::NetworkError.is_retryable());
222        assert!(!ErrorCode::SessionNotFound.is_retryable());
223        assert!(!ErrorCode::SigningFailed.is_retryable());
224    }
225
226    #[test]
227    fn client_error_classification() {
228        assert!(ErrorCode::SessionNotFound.is_client_error());
229        assert!(ErrorCode::PolicyDenied.is_client_error());
230        assert!(!ErrorCode::NetworkError.is_client_error());
231        assert!(!ErrorCode::Internal.is_client_error());
232    }
233
234    #[test]
235    fn descriptions_are_non_empty() {
236        for code in [
237            ErrorCode::SessionNotFound,
238            ErrorCode::SigningFailed,
239            ErrorCode::Internal,
240        ] {
241            assert!(!code.description().is_empty());
242        }
243    }
244
245    #[test]
246    fn display_includes_code_and_description() {
247        let s = format!("{}", ErrorCode::ThresholdNotMet);
248        assert!(s.contains("1003"));
249        assert!(s.contains("threshold"));
250    }
251
252    #[test]
253    fn typed_error_carries_metadata() {
254        let err = TypedError::new(ErrorCode::RateLimited, "too many requests");
255        assert_eq!(err.code, ErrorCode::RateLimited);
256        assert_eq!(err.numeric_code, 2001);
257        assert!(!err.retryable); // rate limited is NOT retryable
258        assert_eq!(err.message, "too many requests");
259    }
260
261    #[test]
262    fn typed_error_serializes() {
263        let err = TypedError::new(ErrorCode::Internal, "unexpected");
264        let json = serde_json::to_string(&err).unwrap();
265        assert!(json.contains("internal"));
266        assert!(json.contains("9001"));
267        assert!(json.contains("unexpected"));
268    }
269
270    #[test]
271    fn error_code_serializes_as_snake_case() {
272        let json = serde_json::to_string(&ErrorCode::SessionExpired).unwrap();
273        assert!(json.contains("session_expired"));
274    }
275}