Skip to main content

awp_types/
error.rs

1use crate::AwpVersion;
2
3/// AWP protocol error with HTTP status code mapping.
4///
5/// Each variant maps to a specific HTTP status code via [`AwpError::status_code()`]
6/// and a snake_case error code via [`AwpError::error_code()`].
7///
8/// # Example
9///
10/// ```
11/// use awp_types::AwpError;
12///
13/// let err = AwpError::NotFound("resource xyz".to_string());
14/// assert_eq!(err.status_code(), 404);
15/// assert_eq!(err.error_code(), "not_found");
16/// ```
17#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum AwpError {
19    #[error("invalid request: {0}")]
20    InvalidRequest(String),
21
22    #[error("unauthorized: {0}")]
23    Unauthorized(String),
24
25    #[error("forbidden: {0}")]
26    Forbidden(String),
27
28    #[error("not found: {0}")]
29    NotFound(String),
30
31    #[error("rate limited: retry after {retry_after_secs}s")]
32    RateLimited { retry_after_secs: u64 },
33
34    #[error("version mismatch: requested {requested}, current {current}")]
35    VersionMismatch { requested: AwpVersion, current: AwpVersion },
36
37    #[error("internal error: {0}")]
38    InternalError(String),
39
40    #[error("service unavailable: {0}")]
41    ServiceUnavailable(String),
42}
43
44impl AwpError {
45    /// Returns the HTTP status code corresponding to this error variant.
46    pub fn status_code(&self) -> u16 {
47        match self {
48            AwpError::InvalidRequest(_) => 400,
49            AwpError::Unauthorized(_) => 401,
50            AwpError::Forbidden(_) => 403,
51            AwpError::NotFound(_) => 404,
52            AwpError::RateLimited { .. } => 429,
53            AwpError::VersionMismatch { .. } => 406,
54            AwpError::InternalError(_) => 500,
55            AwpError::ServiceUnavailable(_) => 503,
56        }
57    }
58
59    /// Returns a snake_case error code string for this variant.
60    pub fn error_code(&self) -> &str {
61        match self {
62            AwpError::InvalidRequest(_) => "invalid_request",
63            AwpError::Unauthorized(_) => "unauthorized",
64            AwpError::Forbidden(_) => "forbidden",
65            AwpError::NotFound(_) => "not_found",
66            AwpError::RateLimited { .. } => "rate_limited",
67            AwpError::VersionMismatch { .. } => "version_mismatch",
68            AwpError::InternalError(_) => "internal_error",
69            AwpError::ServiceUnavailable(_) => "service_unavailable",
70        }
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use crate::CURRENT_VERSION;
78
79    #[test]
80    fn test_status_code_invalid_request() {
81        assert_eq!(AwpError::InvalidRequest("bad".into()).status_code(), 400);
82    }
83
84    #[test]
85    fn test_status_code_unauthorized() {
86        assert_eq!(AwpError::Unauthorized("no token".into()).status_code(), 401);
87    }
88
89    #[test]
90    fn test_status_code_forbidden() {
91        assert_eq!(AwpError::Forbidden("denied".into()).status_code(), 403);
92    }
93
94    #[test]
95    fn test_status_code_not_found() {
96        assert_eq!(AwpError::NotFound("missing".into()).status_code(), 404);
97    }
98
99    #[test]
100    fn test_status_code_rate_limited() {
101        assert_eq!(AwpError::RateLimited { retry_after_secs: 30 }.status_code(), 429);
102    }
103
104    #[test]
105    fn test_status_code_version_mismatch() {
106        let err = AwpError::VersionMismatch {
107            requested: AwpVersion { major: 2, minor: 0 },
108            current: CURRENT_VERSION,
109        };
110        assert_eq!(err.status_code(), 406);
111    }
112
113    #[test]
114    fn test_status_code_internal_error() {
115        assert_eq!(AwpError::InternalError("oops".into()).status_code(), 500);
116    }
117
118    #[test]
119    fn test_status_code_service_unavailable() {
120        assert_eq!(AwpError::ServiceUnavailable("down".into()).status_code(), 503);
121    }
122
123    #[test]
124    fn test_display_non_empty() {
125        let errors: Vec<AwpError> = vec![
126            AwpError::InvalidRequest("bad".into()),
127            AwpError::Unauthorized("no token".into()),
128            AwpError::Forbidden("denied".into()),
129            AwpError::NotFound("missing".into()),
130            AwpError::RateLimited { retry_after_secs: 30 },
131            AwpError::VersionMismatch {
132                requested: AwpVersion { major: 2, minor: 0 },
133                current: CURRENT_VERSION,
134            },
135            AwpError::InternalError("oops".into()),
136            AwpError::ServiceUnavailable("down".into()),
137        ];
138        for err in errors {
139            let msg = err.to_string();
140            assert!(!msg.is_empty(), "Display for {err:?} should be non-empty");
141        }
142    }
143
144    #[test]
145    fn test_error_codes() {
146        assert_eq!(AwpError::InvalidRequest("x".into()).error_code(), "invalid_request");
147        assert_eq!(AwpError::Unauthorized("x".into()).error_code(), "unauthorized");
148        assert_eq!(AwpError::Forbidden("x".into()).error_code(), "forbidden");
149        assert_eq!(AwpError::NotFound("x".into()).error_code(), "not_found");
150        assert_eq!(AwpError::RateLimited { retry_after_secs: 1 }.error_code(), "rate_limited");
151        assert_eq!(
152            AwpError::VersionMismatch { requested: CURRENT_VERSION, current: CURRENT_VERSION }
153                .error_code(),
154            "version_mismatch"
155        );
156        assert_eq!(AwpError::InternalError("x".into()).error_code(), "internal_error");
157        assert_eq!(AwpError::ServiceUnavailable("x".into()).error_code(), "service_unavailable");
158    }
159}