Skip to main content

rskit_grpc/
errors.rs

1use rskit_errors::{AppError, ErrorCode, ProblemDetail};
2use tonic::Status;
3
4/// Convert a tonic [`tonic::Status`] to an [`AppError`].
5///
6/// Maps gRPC status codes to rskit error codes with appropriate HTTP status
7/// and human-readable messages.
8pub fn status_to_app_error(status: Status) -> AppError {
9    if !status.details().is_empty()
10        && let Ok(problem) = serde_json::from_slice::<ProblemDetail>(status.details())
11    {
12        return AppError::new(problem.code, problem.detail)
13            .retryable(problem.retryable)
14            .with_details(problem.details);
15    }
16
17    AppError::new(
18        grpc_code_to_error_code(status.code()),
19        status.message().to_string(),
20    )
21}
22
23/// Convert an [`AppError`] to a tonic [`tonic::Status`].
24///
25/// Maps rskit error codes to appropriate gRPC status codes.
26pub fn app_error_to_status(err: &AppError) -> Status {
27    let problem = ProblemDetail::from(err);
28    if let Ok(json_bytes) = serde_json::to_vec(&problem) {
29        Status::with_details(
30            error_code_to_grpc_code(err.code()),
31            err.message().to_string(),
32            json_bytes.into(),
33        )
34    } else {
35        Status::new(
36            error_code_to_grpc_code(err.code()),
37            err.message().to_string(),
38        )
39    }
40}
41
42/// Map an rskit [`ErrorCode`] to its canonical gRPC [`tonic::Code`].
43#[must_use]
44pub fn error_code_to_grpc_code(code: ErrorCode) -> tonic::Code {
45    match code {
46        ErrorCode::ServiceUnavailable | ErrorCode::ConnectionFailed => tonic::Code::Unavailable,
47        ErrorCode::Timeout => tonic::Code::DeadlineExceeded,
48        ErrorCode::RateLimited => tonic::Code::ResourceExhausted,
49        ErrorCode::NotFound => tonic::Code::NotFound,
50        ErrorCode::AlreadyExists => tonic::Code::AlreadyExists,
51        ErrorCode::Conflict => tonic::Code::Aborted,
52        ErrorCode::InvalidInput | ErrorCode::MissingField | ErrorCode::InvalidFormat => {
53            tonic::Code::InvalidArgument
54        }
55        ErrorCode::Unauthorized | ErrorCode::TokenExpired | ErrorCode::InvalidToken => {
56            tonic::Code::Unauthenticated
57        }
58        ErrorCode::Forbidden => tonic::Code::PermissionDenied,
59        ErrorCode::Internal | ErrorCode::DatabaseError | ErrorCode::ExternalService => {
60            tonic::Code::Internal
61        }
62        ErrorCode::Cancelled => tonic::Code::Cancelled,
63        _ => tonic::Code::Unknown,
64    }
65}
66
67/// Map a gRPC [`tonic::Code`] to its canonical rskit [`ErrorCode`].
68#[must_use]
69pub fn grpc_code_to_error_code(code: tonic::Code) -> ErrorCode {
70    match code {
71        tonic::Code::Unavailable => ErrorCode::ServiceUnavailable,
72        tonic::Code::DeadlineExceeded => ErrorCode::Timeout,
73        tonic::Code::ResourceExhausted => ErrorCode::RateLimited,
74        tonic::Code::NotFound => ErrorCode::NotFound,
75        tonic::Code::AlreadyExists => ErrorCode::AlreadyExists,
76        tonic::Code::Aborted | tonic::Code::FailedPrecondition => ErrorCode::Conflict,
77        tonic::Code::InvalidArgument | tonic::Code::OutOfRange => ErrorCode::InvalidInput,
78        tonic::Code::Unauthenticated => ErrorCode::Unauthorized,
79        tonic::Code::PermissionDenied => ErrorCode::Forbidden,
80        tonic::Code::Cancelled => ErrorCode::Cancelled,
81        tonic::Code::Internal | tonic::Code::Unknown | tonic::Code::Unimplemented => {
82            ErrorCode::Internal
83        }
84        tonic::Code::DataLoss => ErrorCode::ExternalService,
85        _ => ErrorCode::ExternalService,
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn error_code_to_grpc_code_covers_known_error_codes() {
95        let cases = [
96            (ErrorCode::ServiceUnavailable, tonic::Code::Unavailable),
97            (ErrorCode::ConnectionFailed, tonic::Code::Unavailable),
98            (ErrorCode::Timeout, tonic::Code::DeadlineExceeded),
99            (ErrorCode::RateLimited, tonic::Code::ResourceExhausted),
100            (ErrorCode::NotFound, tonic::Code::NotFound),
101            (ErrorCode::AlreadyExists, tonic::Code::AlreadyExists),
102            (ErrorCode::Conflict, tonic::Code::Aborted),
103            (ErrorCode::InvalidInput, tonic::Code::InvalidArgument),
104            (ErrorCode::MissingField, tonic::Code::InvalidArgument),
105            (ErrorCode::InvalidFormat, tonic::Code::InvalidArgument),
106            (ErrorCode::Unauthorized, tonic::Code::Unauthenticated),
107            (ErrorCode::Forbidden, tonic::Code::PermissionDenied),
108            (ErrorCode::TokenExpired, tonic::Code::Unauthenticated),
109            (ErrorCode::InvalidToken, tonic::Code::Unauthenticated),
110            (ErrorCode::Internal, tonic::Code::Internal),
111            (ErrorCode::DatabaseError, tonic::Code::Internal),
112            (ErrorCode::ExternalService, tonic::Code::Internal),
113            (ErrorCode::Cancelled, tonic::Code::Cancelled),
114        ];
115
116        for (error_code, grpc_code) in cases {
117            assert_eq!(error_code_to_grpc_code(error_code), grpc_code);
118        }
119    }
120
121    #[test]
122    fn grpc_code_to_error_code_covers_all_tonic_codes() {
123        let cases = [
124            (tonic::Code::Ok, ErrorCode::ExternalService),
125            (tonic::Code::Cancelled, ErrorCode::Cancelled),
126            (tonic::Code::Unknown, ErrorCode::Internal),
127            (tonic::Code::InvalidArgument, ErrorCode::InvalidInput),
128            (tonic::Code::DeadlineExceeded, ErrorCode::Timeout),
129            (tonic::Code::NotFound, ErrorCode::NotFound),
130            (tonic::Code::AlreadyExists, ErrorCode::AlreadyExists),
131            (tonic::Code::PermissionDenied, ErrorCode::Forbidden),
132            (tonic::Code::ResourceExhausted, ErrorCode::RateLimited),
133            (tonic::Code::FailedPrecondition, ErrorCode::Conflict),
134            (tonic::Code::Aborted, ErrorCode::Conflict),
135            (tonic::Code::OutOfRange, ErrorCode::InvalidInput),
136            (tonic::Code::Unimplemented, ErrorCode::Internal),
137            (tonic::Code::Internal, ErrorCode::Internal),
138            (tonic::Code::Unavailable, ErrorCode::ServiceUnavailable),
139            (tonic::Code::DataLoss, ErrorCode::ExternalService),
140            (tonic::Code::Unauthenticated, ErrorCode::Unauthorized),
141        ];
142
143        for (grpc_code, error_code) in cases {
144            assert_eq!(grpc_code_to_error_code(grpc_code), error_code);
145        }
146    }
147
148    #[test]
149    fn test_status_to_error_not_found() {
150        let status = tonic::Status::not_found("user not found");
151        let err = status_to_app_error(status);
152        assert_eq!(err.code(), ErrorCode::NotFound);
153    }
154
155    #[test]
156    fn test_status_to_error_invalid_argument() {
157        let status = tonic::Status::invalid_argument("invalid request");
158        let err = status_to_app_error(status);
159        assert_eq!(err.code(), ErrorCode::InvalidInput);
160    }
161
162    #[test]
163    fn test_status_to_error_unavailable() {
164        let status = tonic::Status::unavailable("service down");
165        let err = status_to_app_error(status);
166        assert_eq!(err.code(), ErrorCode::ServiceUnavailable);
167    }
168
169    #[test]
170    fn test_status_to_error_unauthenticated() {
171        let status = tonic::Status::unauthenticated("invalid token");
172        let err = status_to_app_error(status);
173        assert_eq!(err.code(), ErrorCode::Unauthorized);
174    }
175
176    #[test]
177    fn test_app_error_to_status_not_found() {
178        let err = AppError::new(ErrorCode::NotFound, "user not found");
179        let status = app_error_to_status(&err);
180        assert_eq!(status.code(), tonic::Code::NotFound);
181    }
182
183    #[test]
184    fn test_app_error_to_status_invalid_input() {
185        let err = AppError::new(ErrorCode::InvalidInput, "bad request");
186        let status = app_error_to_status(&err);
187        assert_eq!(status.code(), tonic::Code::InvalidArgument);
188    }
189
190    #[test]
191    fn test_app_error_to_status_unauthorized() {
192        let err = AppError::new(ErrorCode::Unauthorized, "missing token");
193        let status = app_error_to_status(&err);
194        assert_eq!(status.code(), tonic::Code::Unauthenticated);
195    }
196
197    #[test]
198    fn test_status_to_error_cancelled_uses_canonical_errors_mapping() {
199        let err = status_to_app_error(tonic::Status::cancelled("client cancelled"));
200        assert_eq!(err.code(), ErrorCode::Cancelled);
201    }
202
203    #[test]
204    fn app_error_to_status_preserves_problem_details() {
205        let err = AppError::new(ErrorCode::NotFound, "user 42 not found").with_detail("id", "42");
206
207        let status = app_error_to_status(&err);
208        let recovered = status_to_app_error(status);
209
210        assert_eq!(recovered.code(), ErrorCode::NotFound);
211        assert_eq!(recovered.message(), "user 42 not found");
212        assert_eq!(
213            recovered
214                .details()
215                .get("id")
216                .and_then(|value| value.as_str()),
217            Some("42")
218        );
219    }
220}