Skip to main content

koi_common/
error.rs

1use serde::{Deserialize, Serialize};
2use utoipa::ToSchema;
3
4/// Machine-readable error codes for the wire protocol.
5/// Shared by all transports and domains.
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
7#[serde(rename_all = "snake_case")]
8pub enum ErrorCode {
9    InvalidType,
10    InvalidName,
11    InvalidPayload,
12    NotFound,
13    Conflict,
14    SessionMismatch,
15    ResolveTimeout,
16    DaemonError,
17    IoError,
18    AlreadyDraining,
19    NotDraining,
20    AmbiguousId,
21    ParseError,
22    ShuttingDown,
23    Internal,
24    // Certmesh (Phase 2)
25    CaNotInitialized,
26    CaLocked,
27    InvalidAuth,
28    RateLimited,
29    EnrollmentClosed,
30    CapabilityDisabled,
31    // Certmesh (Phase 3)
32    NotStandby,
33    PromotionFailed,
34    RenewalFailed,
35    InvalidManifest,
36    // Certmesh (Phase 4)
37    ScopeViolation,
38    ApprovalDenied,
39    ApprovalTimeout,
40    ApprovalUnavailable,
41    // Certmesh (Phase 5)
42    Revoked,
43}
44
45impl ErrorCode {
46    /// Suggested HTTP status code for this error.
47    /// Transport-agnostic (returns u16, not an axum type).
48    pub fn http_status(&self) -> u16 {
49        match self {
50            Self::InvalidType
51            | Self::InvalidName
52            | Self::InvalidPayload
53            | Self::AmbiguousId
54            | Self::ParseError => 400,
55            Self::SessionMismatch => 403,
56            Self::NotFound => 404,
57            Self::Conflict | Self::AlreadyDraining | Self::NotDraining => 409,
58            Self::ResolveTimeout => 504,
59            Self::ShuttingDown
60            | Self::CaNotInitialized
61            | Self::CaLocked
62            | Self::CapabilityDisabled => 503,
63            Self::InvalidAuth => 401,
64            Self::RateLimited => 429,
65            Self::EnrollmentClosed
66            | Self::NotStandby
67            | Self::ScopeViolation
68            | Self::ApprovalDenied => 403,
69            Self::Revoked => 403,
70            Self::DaemonError
71            | Self::IoError
72            | Self::Internal
73            | Self::PromotionFailed
74            | Self::RenewalFailed => 500,
75            Self::InvalidManifest => 400,
76            Self::ApprovalTimeout => 504,
77            Self::ApprovalUnavailable => 503,
78        }
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn error_code_serializes_to_snake_case() {
88        assert_eq!(
89            serde_json::to_value(ErrorCode::InvalidType).unwrap(),
90            "invalid_type"
91        );
92        assert_eq!(
93            serde_json::to_value(ErrorCode::NotFound).unwrap(),
94            "not_found"
95        );
96        assert_eq!(
97            serde_json::to_value(ErrorCode::AlreadyDraining).unwrap(),
98            "already_draining"
99        );
100    }
101
102    /// Exhaustive test covering every ErrorCode variant → HTTP status mapping.
103    /// Adding a new ErrorCode variant forces a compile error here until the
104    /// mapping is explicitly verified.
105    #[test]
106    fn all_error_code_variants_map_to_expected_http_status() {
107        let cases: Vec<(ErrorCode, u16)> = vec![
108            // 400 Bad Request
109            (ErrorCode::InvalidType, 400),
110            (ErrorCode::InvalidName, 400),
111            (ErrorCode::InvalidPayload, 400),
112            (ErrorCode::AmbiguousId, 400),
113            (ErrorCode::ParseError, 400),
114            // 401 Unauthorized
115            (ErrorCode::InvalidAuth, 401),
116            // 403 Forbidden
117            (ErrorCode::SessionMismatch, 403),
118            (ErrorCode::EnrollmentClosed, 403),
119            // 404 Not Found
120            (ErrorCode::NotFound, 404),
121            // 409 Conflict
122            (ErrorCode::Conflict, 409),
123            (ErrorCode::AlreadyDraining, 409),
124            (ErrorCode::NotDraining, 409),
125            // 429 Rate Limited
126            (ErrorCode::RateLimited, 429),
127            // 400 Bad Request (Phase 3)
128            (ErrorCode::InvalidManifest, 400),
129            // 403 Forbidden (Phase 3)
130            (ErrorCode::NotStandby, 403),
131            // 403 Forbidden (Phase 4)
132            (ErrorCode::ScopeViolation, 403),
133            (ErrorCode::Revoked, 403),
134            (ErrorCode::ApprovalDenied, 403),
135            // 500 Internal Server Error
136            (ErrorCode::DaemonError, 500),
137            (ErrorCode::IoError, 500),
138            (ErrorCode::Internal, 500),
139            (ErrorCode::PromotionFailed, 500),
140            (ErrorCode::RenewalFailed, 500),
141            // 503 Service Unavailable
142            (ErrorCode::ShuttingDown, 503),
143            (ErrorCode::CaNotInitialized, 503),
144            (ErrorCode::CaLocked, 503),
145            (ErrorCode::CapabilityDisabled, 503),
146            (ErrorCode::ApprovalUnavailable, 503),
147            // 504 Gateway Timeout
148            (ErrorCode::ResolveTimeout, 504),
149            (ErrorCode::ApprovalTimeout, 504),
150        ];
151        for (code, expected_status) in &cases {
152            assert_eq!(
153                code.http_status(),
154                *expected_status,
155                "{code:?} should map to HTTP {expected_status}"
156            );
157        }
158    }
159
160    /// Exhaustive serde round-trip for all ErrorCode variants.
161    #[test]
162    fn all_error_code_variants_roundtrip_through_json() {
163        let variants: Vec<(ErrorCode, &str)> = vec![
164            (ErrorCode::InvalidType, "invalid_type"),
165            (ErrorCode::InvalidName, "invalid_name"),
166            (ErrorCode::InvalidPayload, "invalid_payload"),
167            (ErrorCode::NotFound, "not_found"),
168            (ErrorCode::Conflict, "conflict"),
169            (ErrorCode::SessionMismatch, "session_mismatch"),
170            (ErrorCode::ResolveTimeout, "resolve_timeout"),
171            (ErrorCode::DaemonError, "daemon_error"),
172            (ErrorCode::IoError, "io_error"),
173            (ErrorCode::AlreadyDraining, "already_draining"),
174            (ErrorCode::NotDraining, "not_draining"),
175            (ErrorCode::AmbiguousId, "ambiguous_id"),
176            (ErrorCode::ParseError, "parse_error"),
177            (ErrorCode::ShuttingDown, "shutting_down"),
178            (ErrorCode::Internal, "internal"),
179            (ErrorCode::CaNotInitialized, "ca_not_initialized"),
180            (ErrorCode::CaLocked, "ca_locked"),
181            (ErrorCode::InvalidAuth, "invalid_auth"),
182            (ErrorCode::RateLimited, "rate_limited"),
183            (ErrorCode::EnrollmentClosed, "enrollment_closed"),
184            (ErrorCode::CapabilityDisabled, "capability_disabled"),
185            (ErrorCode::NotStandby, "not_standby"),
186            (ErrorCode::PromotionFailed, "promotion_failed"),
187            (ErrorCode::RenewalFailed, "renewal_failed"),
188            (ErrorCode::InvalidManifest, "invalid_manifest"),
189            (ErrorCode::ScopeViolation, "scope_violation"),
190            (ErrorCode::Revoked, "revoked"),
191            (ErrorCode::ApprovalDenied, "approval_denied"),
192            (ErrorCode::ApprovalTimeout, "approval_timeout"),
193            (ErrorCode::ApprovalUnavailable, "approval_unavailable"),
194        ];
195        for (code, expected_str) in &variants {
196            let serialized = serde_json::to_value(code).unwrap();
197            assert_eq!(
198                serialized, *expected_str,
199                "{code:?} should serialize to \"{expected_str}\""
200            );
201
202            let deserialized: ErrorCode = serde_json::from_value(serialized).unwrap();
203            assert_eq!(
204                &deserialized, code,
205                "\"{expected_str}\" should deserialize back to {code:?}"
206            );
207        }
208    }
209}