Skip to main content

ecat_errors/
lib.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2mod codes;
3
4use codes::ErrorCodeExt;
5pub use ecat_protos::errors::ErrorCode;
6use std::collections::HashMap;
7
8#[derive(Debug, thiserror::Error)]
9pub struct Error {
10    pub code: ErrorCode,
11    pub reason: String,
12    pub message: String,
13    #[source]
14    pub cause: Option<Box<dyn std::error::Error + Send + Sync>>,
15    pub metadata: HashMap<String, String>,
16}
17
18impl std::fmt::Display for Error {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        write!(f, "[{:?}] {}: {}", self.code, self.reason, self.message)
21    }
22}
23
24impl Error {
25    pub fn new(code: ErrorCode, reason: impl Into<String>, message: impl Into<String>) -> Self {
26        Self {
27            code,
28            reason: reason.into(),
29            message: message.into(),
30            cause: None,
31            metadata: HashMap::new(),
32        }
33    }
34
35    pub fn message(&self) -> &str {
36        &self.message
37    }
38
39    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
40        self.metadata.insert(key.into(), value.into());
41        self
42    }
43
44    pub fn from_status(status: tonic::Status) -> Self {
45        let code = match status.code() {
46            tonic::Code::Ok => ErrorCode::Ok,
47            tonic::Code::NotFound => ErrorCode::NotFound,
48            tonic::Code::InvalidArgument => ErrorCode::InvalidArgument,
49            tonic::Code::PermissionDenied => ErrorCode::PermissionDenied,
50            tonic::Code::Unauthenticated => ErrorCode::Unauthenticated,
51            tonic::Code::ResourceExhausted => ErrorCode::ResourceExhausted,
52            tonic::Code::AlreadyExists => ErrorCode::AlreadyExists,
53            tonic::Code::Unavailable => ErrorCode::Unavailable,
54            tonic::Code::DeadlineExceeded => ErrorCode::DeadlineExceeded,
55            tonic::Code::Unknown => ErrorCode::Unknown,
56            _ => ErrorCode::Internal,
57        };
58        Self::new(code, "grpc_error", status.message().to_string())
59    }
60
61    pub fn to_http_status(&self) -> http::StatusCode {
62        self.code.http_status()
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use http::StatusCode;
70
71    #[test]
72    fn test_error_code_http_mapping() {
73        assert_eq!(ErrorCode::Ok.http_status(), StatusCode::OK);
74        assert_eq!(ErrorCode::NotFound.http_status(), StatusCode::NOT_FOUND);
75        assert_eq!(
76            ErrorCode::InvalidArgument.http_status(),
77            StatusCode::BAD_REQUEST
78        );
79        assert_eq!(
80            ErrorCode::PermissionDenied.http_status(),
81            StatusCode::FORBIDDEN
82        );
83        assert_eq!(
84            ErrorCode::Unauthenticated.http_status(),
85            StatusCode::UNAUTHORIZED
86        );
87        assert_eq!(
88            ErrorCode::Internal.http_status(),
89            StatusCode::INTERNAL_SERVER_ERROR
90        );
91        assert_eq!(
92            ErrorCode::Unavailable.http_status(),
93            StatusCode::SERVICE_UNAVAILABLE
94        );
95        assert_eq!(
96            ErrorCode::DeadlineExceeded.http_status(),
97            StatusCode::GATEWAY_TIMEOUT
98        );
99    }
100
101    #[test]
102    fn test_from_status_maps_codes() {
103        let err = Error::from_status(tonic::Status::not_found("missing"));
104        assert_eq!(err.code, ErrorCode::NotFound);
105
106        let err = Error::from_status(tonic::Status::permission_denied("nope"));
107        assert_eq!(err.code, ErrorCode::PermissionDenied);
108
109        let err = Error::from_status(tonic::Status::unauthenticated("bad token"));
110        assert_eq!(err.code, ErrorCode::Unauthenticated);
111    }
112
113    #[test]
114    fn test_with_metadata_accumulates() {
115        let err = Error::new(ErrorCode::Ok, "test", "msg")
116            .with_metadata("key1", "val1")
117            .with_metadata("key2", "val2");
118        assert_eq!(err.metadata.get("key1").unwrap(), "val1");
119        assert_eq!(err.metadata.get("key2").unwrap(), "val2");
120    }
121
122    #[test]
123    fn test_display_format() {
124        let err = Error::new(ErrorCode::NotFound, "user_not_found", "user 42 not found");
125        let s = err.to_string();
126        assert!(s.contains("NotFound"));
127        assert!(s.contains("user_not_found"));
128        assert!(s.contains("user 42 not found"));
129    }
130
131    #[test]
132    fn test_remaining_http_mappings() {
133        assert_eq!(ErrorCode::AlreadyExists.http_status(), StatusCode::CONFLICT);
134        assert_eq!(
135            ErrorCode::ResourceExhausted.http_status(),
136            StatusCode::TOO_MANY_REQUESTS
137        );
138        assert_eq!(
139            ErrorCode::Unknown.http_status(),
140            StatusCode::INTERNAL_SERVER_ERROR
141        );
142        assert_eq!(
143            ErrorCode::Internal.http_status(),
144            StatusCode::INTERNAL_SERVER_ERROR
145        );
146    }
147
148    #[test]
149    fn test_from_status_remaining_codes() {
150        assert_eq!(
151            Error::from_status(tonic::Status::already_exists("dup")).code,
152            ErrorCode::AlreadyExists
153        );
154        assert_eq!(
155            Error::from_status(tonic::Status::resource_exhausted("limit")).code,
156            ErrorCode::ResourceExhausted
157        );
158        assert_eq!(
159            Error::from_status(tonic::Status::unavailable("down")).code,
160            ErrorCode::Unavailable
161        );
162        assert_eq!(
163            Error::from_status(tonic::Status::deadline_exceeded("slow")).code,
164            ErrorCode::DeadlineExceeded
165        );
166        assert_eq!(
167            Error::from_status(tonic::Status::unknown("x")).code,
168            ErrorCode::Unknown
169        );
170        assert_eq!(
171            Error::from_status(tonic::Status::internal("boom")).code,
172            ErrorCode::Internal
173        );
174    }
175
176    #[test]
177    fn test_from_status_unmapped_code_becomes_internal() {
178        let err = Error::from_status(tonic::Status::failed_precondition("nope"));
179        assert_eq!(err.code, ErrorCode::Internal);
180        let err = Error::from_status(tonic::Status::new(tonic::Code::Ok, ""));
181        assert_eq!(err.code, ErrorCode::Ok);
182    }
183
184    #[test]
185    fn test_to_http_status() {
186        let err = Error::new(ErrorCode::Unavailable, "x", "y");
187        assert_eq!(err.to_http_status(), StatusCode::SERVICE_UNAVAILABLE);
188    }
189
190    #[test]
191    fn test_cause_is_exposed_via_source() {
192        use std::error::Error as _;
193        let inner = std::io::Error::other("io boom");
194        let err = Error {
195            cause: Some(Box::new(inner)),
196            ..Error::new(ErrorCode::Internal, "io", "failed")
197        };
198        assert!(err.source().is_some());
199        let no_cause = Error::new(ErrorCode::Ok, "x", "y");
200        assert!(no_cause.source().is_none());
201    }
202}