twurst_error/
lib.rs

1#![doc = include_str!("../README.md")]
2#![doc(
3    test(attr(deny(warnings))),
4    html_favicon_url = "https://raw.githubusercontent.com/helsing-ai/twurst/main/docs/img/twurst.png",
5    html_logo_url = "https://raw.githubusercontent.com/helsing-ai/twurst/main/docs/img/twurst.png"
6)]
7#![cfg_attr(docsrs, feature(doc_auto_cfg))]
8
9use std::collections::HashMap;
10use std::error::Error;
11use std::fmt;
12use std::sync::Arc;
13
14/// A Twirp [error](https://twitchtv.github.io/twirp/docs/spec_v7.html#errors)
15///
16/// It is composed of three elements:
17/// - An error `code` that is member of a fixed list [`TwirpErrorCode`]
18/// - A human error `message` describing the error as a string
19/// - A set of "`meta`" key-value pairs as strings holding arbitrary metadata describing the error.
20///
21/// ```
22/// # use twurst_error::{TwirpError, TwirpErrorCode};
23/// let error = TwirpError::not_found("Object foo not found").with_meta("id", "foo");
24/// assert_eq!(error.code(), TwirpErrorCode::NotFound);
25/// assert_eq!(error.message(), "Object foo not found");
26/// assert_eq!(error.meta("id"), Some("foo"));
27/// ```
28#[derive(Clone, Debug)]
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30pub struct TwirpError {
31    /// The [error code](https://twitchtv.github.io/twirp/docs/spec_v7.html#error-codes)
32    code: TwirpErrorCode,
33    /// The error message (human description of the error)
34    msg: String,
35    /// Some metadata describing the error
36    #[cfg_attr(
37        feature = "serde",
38        serde(default, skip_serializing_if = "HashMap::is_empty")
39    )]
40    meta: HashMap<String, String>,
41    #[cfg_attr(feature = "serde", serde(default, skip))]
42    source: Option<Arc<dyn Error + Send + Sync>>,
43}
44
45impl TwirpError {
46    #[inline]
47    pub fn code(&self) -> TwirpErrorCode {
48        self.code
49    }
50
51    #[inline]
52    pub fn message(&self) -> &str {
53        &self.msg
54    }
55
56    #[inline]
57    pub fn into_message(self) -> String {
58        self.msg
59    }
60
61    /// Get an associated metadata
62    #[inline]
63    pub fn meta(&self, key: &str) -> Option<&str> {
64        self.meta.get(key).map(|s| s.as_str())
65    }
66
67    /// Get all associated metadata
68    #[inline]
69    pub fn meta_iter(&self) -> impl Iterator<Item = (&str, &str)> {
70        self.meta.iter().map(|(k, v)| (k.as_str(), v.as_str()))
71    }
72
73    #[inline]
74    pub fn new(code: TwirpErrorCode, msg: impl Into<String>) -> Self {
75        Self {
76            code,
77            msg: msg.into(),
78            meta: HashMap::new(),
79            source: None,
80        }
81    }
82
83    #[inline]
84    pub fn wrap(
85        code: TwirpErrorCode,
86        msg: impl Into<String>,
87        e: impl Error + Send + Sync + 'static,
88    ) -> Self {
89        Self {
90            code,
91            msg: msg.into(),
92            meta: HashMap::new(),
93            source: Some(Arc::new(e)),
94        }
95    }
96
97    /// Set an associated metadata
98    #[inline]
99    pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
100        self.meta.insert(key.into(), value.into());
101        self
102    }
103
104    #[inline]
105    pub fn aborted(msg: impl Into<String>) -> Self {
106        Self::new(TwirpErrorCode::Aborted, msg)
107    }
108
109    #[inline]
110    pub fn already_exists(msg: impl Into<String>) -> Self {
111        Self::new(TwirpErrorCode::AlreadyExists, msg)
112    }
113
114    #[inline]
115    pub fn canceled(msg: impl Into<String>) -> Self {
116        Self::new(TwirpErrorCode::Canceled, msg)
117    }
118
119    #[inline]
120    pub fn dataloss(msg: impl Into<String>) -> Self {
121        Self::new(TwirpErrorCode::Dataloss, msg)
122    }
123
124    #[inline]
125    pub fn invalid_argument(msg: impl Into<String>) -> Self {
126        Self::new(TwirpErrorCode::InvalidArgument, msg)
127    }
128
129    #[inline]
130    pub fn internal(msg: impl Into<String>) -> Self {
131        Self::new(TwirpErrorCode::Internal, msg)
132    }
133
134    #[inline]
135    pub fn deadline_exceeded(msg: impl Into<String>) -> Self {
136        Self::new(TwirpErrorCode::DeadlineExceeded, msg)
137    }
138
139    #[inline]
140    pub fn failed_precondition(msg: impl Into<String>) -> Self {
141        Self::new(TwirpErrorCode::FailedPrecondition, msg)
142    }
143
144    #[inline]
145    pub fn malformed(msg: impl Into<String>) -> Self {
146        Self::new(TwirpErrorCode::Malformed, msg)
147    }
148
149    #[inline]
150    pub fn not_found(msg: impl Into<String>) -> Self {
151        Self::new(TwirpErrorCode::NotFound, msg)
152    }
153
154    #[inline]
155    pub fn out_of_range(msg: impl Into<String>) -> Self {
156        Self::new(TwirpErrorCode::OutOfRange, msg)
157    }
158
159    #[inline]
160    pub fn permission_denied(msg: impl Into<String>) -> Self {
161        Self::new(TwirpErrorCode::PermissionDenied, msg)
162    }
163
164    #[inline]
165    pub fn required_argument(msg: impl Into<String>) -> Self {
166        Self::invalid_argument(msg)
167    }
168
169    #[inline]
170    pub fn resource_exhausted(msg: impl Into<String>) -> Self {
171        Self::new(TwirpErrorCode::ResourceExhausted, msg)
172    }
173
174    #[inline]
175    pub fn unauthenticated(msg: impl Into<String>) -> Self {
176        Self::new(TwirpErrorCode::Unauthenticated, msg)
177    }
178
179    #[inline]
180    pub fn unavailable(msg: impl Into<String>) -> Self {
181        Self::new(TwirpErrorCode::Unavailable, msg)
182    }
183
184    #[inline]
185    pub fn unimplemented(msg: impl Into<String>) -> Self {
186        Self::new(TwirpErrorCode::Unimplemented, msg)
187    }
188}
189
190impl fmt::Display for TwirpError {
191    #[inline]
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        write!(f, "Twirp {:?} error: {}", self.code, self.msg)
194    }
195}
196
197impl Error for TwirpError {
198    #[inline]
199    fn source(&self) -> Option<&(dyn Error + 'static)> {
200        Some(self.source.as_ref()?)
201    }
202}
203
204impl PartialEq for TwirpError {
205    #[inline]
206    fn eq(&self, other: &Self) -> bool {
207        self.code == other.code && self.msg == other.msg && self.meta == other.meta
208    }
209}
210
211impl Eq for TwirpError {}
212
213/// A Twirp [error code](https://twitchtv.github.io/twirp/docs/spec_v7.html#error-codes)
214#[derive(Clone, Copy, Debug, PartialEq, Eq)]
215#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
216#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
217pub enum TwirpErrorCode {
218    /// The operation was cancelled.
219    Canceled,
220    /// An unknown error occurred. For example, this can be used when handling errors raised by APIs that do not return any error information.
221    Unknown,
222    /// The client specified an invalid argument. This indicates arguments that are invalid regardless of the state of the system (i.e. a malformed file name, required argument, number out of range, etc.).
223    InvalidArgument,
224    /// The client sent a message which could not be decoded. This may mean that the message was encoded improperly or that the client and server have incompatible message definitions.
225    Malformed,
226    /// Operation expired before completion. For operations that change the state of the system, this error may be returned even if the operation has completed successfully (timeout).
227    DeadlineExceeded,
228    /// Some requested entity was not found.
229    NotFound,
230    /// The requested URL path wasn't routable to a Twirp service and method. This is returned by generated server code and should not be returned by application code (use "not_found" or "unimplemented" instead).
231    BadRoute,
232    /// An attempt to create an entity failed because one already exists.
233    AlreadyExists,
234    /// The caller does not have permission to execute the specified operation. It must not be used if the caller cannot be identified (use "unauthenticated" instead).
235    PermissionDenied,
236    /// The request does not have valid authentication credentials for the operation.
237    Unauthenticated,
238    /// Some resource has been exhausted or rate-limited, perhaps a per-user quota, or perhaps the entire file system is out of space.
239    ResourceExhausted,
240    /// The operation was rejected because the system is not in a state required for the operation's execution. For example, doing an rmdir operation on a directory that is non-empty, or on a non-directory object, or when having conflicting read-modify-write on the same resource.
241    FailedPrecondition,
242    /// The operation was aborted, typically due to a concurrency issue like sequencer check failures, transaction aborts, etc.
243    Aborted,
244    /// The operation was attempted past the valid range. For example, seeking or reading past end of a paginated collection. Unlike "invalid_argument", this error indicates a problem that may be fixed if the system state changes (i.e. adding more items to the collection). There is a fair bit of overlap between "failed_precondition" and "out_of_range". We recommend using "out_of_range" (the more specific error) when it applies so that callers who are iterating through a space can easily look for an "out_of_range" error to detect when they are done.
245    OutOfRange,
246    /// The operation is not implemented or not supported/enabled in this service.
247    Unimplemented,
248    /// When some invariants expected by the underlying system have been broken. In other words, something bad happened in the library or backend service. Twirp specific issues like wire and serialization problems are also reported as "internal" errors.
249    Internal,
250    /// The service is currently unavailable. This is most likely a transient condition and may be corrected by retrying with a backoff.
251    Unavailable,
252    /// The operation resulted in unrecoverable data loss or corruption.
253    Dataloss,
254}
255
256/// Applies the mapping defined in [Twirp spec](https://twitchtv.github.io/twirp/docs/spec_v7.html#error-codes)
257#[cfg(feature = "http")]
258impl From<TwirpErrorCode> for http::StatusCode {
259    #[inline]
260    fn from(code: TwirpErrorCode) -> Self {
261        match code {
262            TwirpErrorCode::Canceled => Self::REQUEST_TIMEOUT,
263            TwirpErrorCode::Unknown => Self::INTERNAL_SERVER_ERROR,
264            TwirpErrorCode::InvalidArgument => Self::BAD_REQUEST,
265            TwirpErrorCode::Malformed => Self::BAD_REQUEST,
266            TwirpErrorCode::DeadlineExceeded => Self::REQUEST_TIMEOUT,
267            TwirpErrorCode::NotFound => Self::NOT_FOUND,
268            TwirpErrorCode::BadRoute => Self::NOT_FOUND,
269            TwirpErrorCode::AlreadyExists => Self::CONFLICT,
270            TwirpErrorCode::PermissionDenied => Self::FORBIDDEN,
271            TwirpErrorCode::Unauthenticated => Self::UNAUTHORIZED,
272            TwirpErrorCode::ResourceExhausted => Self::TOO_MANY_REQUESTS,
273            TwirpErrorCode::FailedPrecondition => Self::PRECONDITION_FAILED,
274            TwirpErrorCode::Aborted => Self::CONFLICT,
275            TwirpErrorCode::OutOfRange => Self::BAD_REQUEST,
276            TwirpErrorCode::Unimplemented => Self::NOT_IMPLEMENTED,
277            TwirpErrorCode::Internal => Self::INTERNAL_SERVER_ERROR,
278            TwirpErrorCode::Unavailable => Self::SERVICE_UNAVAILABLE,
279            TwirpErrorCode::Dataloss => Self::SERVICE_UNAVAILABLE,
280        }
281    }
282}
283
284#[cfg(feature = "http")]
285impl<B: From<String>> From<TwirpError> for http::Response<B> {
286    fn from(error: TwirpError) -> Self {
287        let json = serde_json::to_string(&error).unwrap();
288        http::Response::builder()
289            .status(error.code)
290            .header(http::header::CONTENT_TYPE, "application/json")
291            .extension(error)
292            .body(json.into())
293            .unwrap()
294    }
295}
296
297#[cfg(feature = "http")]
298impl<B: AsRef<[u8]>> From<http::Response<B>> for TwirpError {
299    fn from(response: http::Response<B>) -> Self {
300        if let Some(error) = response.extensions().get::<Self>() {
301            // We got a ready to use error in the extensions, let's use it
302            return error.clone();
303        }
304        // We are lenient here, a bad error is better than no error at all
305        let status = response.status();
306        let body = response.into_body();
307        if let Ok(error) = serde_json::from_slice::<TwirpError>(body.as_ref()) {
308            // The body is an error, we use it
309            return error;
310        }
311        // We don't have a Twirp error, we build a fallback
312        let code = if status == http::StatusCode::REQUEST_TIMEOUT {
313            TwirpErrorCode::DeadlineExceeded
314        } else if status == http::StatusCode::FORBIDDEN {
315            TwirpErrorCode::PermissionDenied
316        } else if status == http::StatusCode::UNAUTHORIZED {
317            TwirpErrorCode::Unauthenticated
318        } else if status == http::StatusCode::TOO_MANY_REQUESTS {
319            TwirpErrorCode::ResourceExhausted
320        } else if status == http::StatusCode::PRECONDITION_FAILED {
321            TwirpErrorCode::FailedPrecondition
322        } else if status == http::StatusCode::NOT_IMPLEMENTED {
323            TwirpErrorCode::Unimplemented
324        } else if status == http::StatusCode::TOO_MANY_REQUESTS
325            || status == http::StatusCode::BAD_GATEWAY
326            || status == http::StatusCode::SERVICE_UNAVAILABLE
327            || status == http::StatusCode::GATEWAY_TIMEOUT
328        {
329            TwirpErrorCode::Unavailable
330        } else if status == http::StatusCode::NOT_FOUND {
331            TwirpErrorCode::NotFound
332        } else if status.is_server_error() {
333            TwirpErrorCode::Internal
334        } else if status.is_client_error() {
335            TwirpErrorCode::Malformed
336        } else {
337            TwirpErrorCode::Unknown
338        };
339        TwirpError::new(code, String::from_utf8_lossy(body.as_ref()))
340    }
341}
342
343#[cfg(feature = "axum-08")]
344impl axum_core_05::response::IntoResponse for TwirpError {
345    #[inline]
346    fn into_response(self) -> axum_core_05::response::Response {
347        self.into()
348    }
349}
350
351#[cfg(feature = "tonic-014")]
352impl From<TwirpErrorCode> for tonic_014::Code {
353    #[inline]
354    fn from(code: TwirpErrorCode) -> Self {
355        match code {
356            TwirpErrorCode::Canceled => Self::Cancelled,
357            TwirpErrorCode::Unknown => Self::Unknown,
358            TwirpErrorCode::InvalidArgument => Self::InvalidArgument,
359            TwirpErrorCode::Malformed => Self::InvalidArgument,
360            TwirpErrorCode::DeadlineExceeded => Self::DeadlineExceeded,
361            TwirpErrorCode::NotFound => Self::NotFound,
362            TwirpErrorCode::BadRoute => Self::NotFound,
363            TwirpErrorCode::AlreadyExists => Self::AlreadyExists,
364            TwirpErrorCode::PermissionDenied => Self::PermissionDenied,
365            TwirpErrorCode::Unauthenticated => Self::Unauthenticated,
366            TwirpErrorCode::ResourceExhausted => Self::ResourceExhausted,
367            TwirpErrorCode::FailedPrecondition => Self::FailedPrecondition,
368            TwirpErrorCode::Aborted => Self::Aborted,
369            TwirpErrorCode::OutOfRange => Self::OutOfRange,
370            TwirpErrorCode::Unimplemented => Self::Unimplemented,
371            TwirpErrorCode::Internal => Self::Internal,
372            TwirpErrorCode::Unavailable => Self::Unavailable,
373            TwirpErrorCode::Dataloss => Self::DataLoss,
374        }
375    }
376}
377
378#[cfg(feature = "tonic-014")]
379impl From<TwirpError> for tonic_014::Status {
380    #[inline]
381    fn from(error: TwirpError) -> Self {
382        if let Some(source) = &error.source {
383            if let Some(status) = source.downcast_ref::<tonic_014::Status>() {
384                if status.code() == error.code().into() && status.message() == error.message() {
385                    // This is a status wrapped as a Twirp error, we reuse the status to keep the details
386                    return status.clone();
387                }
388            }
389        }
390        Self::new(error.code().into(), error.into_message())
391    }
392}
393
394#[cfg(feature = "tonic-014")]
395impl From<tonic_014::Code> for TwirpErrorCode {
396    #[inline]
397    fn from(code: tonic_014::Code) -> TwirpErrorCode {
398        match code {
399            tonic_014::Code::Cancelled => Self::Canceled,
400            tonic_014::Code::Unknown => Self::Unknown,
401            tonic_014::Code::InvalidArgument => Self::InvalidArgument,
402            tonic_014::Code::DeadlineExceeded => Self::DeadlineExceeded,
403            tonic_014::Code::NotFound => Self::NotFound,
404            tonic_014::Code::AlreadyExists => Self::AlreadyExists,
405            tonic_014::Code::PermissionDenied => Self::PermissionDenied,
406            tonic_014::Code::Unauthenticated => Self::Unauthenticated,
407            tonic_014::Code::ResourceExhausted => Self::ResourceExhausted,
408            tonic_014::Code::FailedPrecondition => Self::FailedPrecondition,
409            tonic_014::Code::Aborted => Self::Aborted,
410            tonic_014::Code::OutOfRange => Self::OutOfRange,
411            tonic_014::Code::Unimplemented => Self::Unimplemented,
412            tonic_014::Code::Internal => Self::Internal,
413            tonic_014::Code::Unavailable => Self::Unavailable,
414            tonic_014::Code::DataLoss => Self::Dataloss,
415            tonic_014::Code::Ok => Self::Unknown,
416        }
417    }
418}
419
420#[cfg(feature = "tonic-014")]
421impl From<tonic_014::Status> for TwirpError {
422    #[inline]
423    fn from(status: tonic_014::Status) -> TwirpError {
424        Self::wrap(status.code().into(), status.message().to_string(), status)
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    #[cfg(feature = "http")]
432    use std::error::Error;
433
434    #[test]
435    fn test_accessors() {
436        let error = TwirpError::invalid_argument("foo is wrong").with_meta("foo", "bar");
437        assert_eq!(error.code(), TwirpErrorCode::InvalidArgument);
438        assert_eq!(error.message(), "foo is wrong");
439        assert_eq!(error.meta("foo"), Some("bar"));
440    }
441
442    #[cfg(feature = "http")]
443    #[test]
444    fn test_to_response() -> Result<(), Box<dyn Error>> {
445        let object =
446            TwirpError::permission_denied("Thou shall not pass").with_meta("target", "Balrog");
447        let response = http::Response::<Vec<u8>>::from(object);
448        assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
449        assert_eq!(
450            response.headers().get(http::header::CONTENT_TYPE),
451            Some(&http::HeaderValue::from_static("application/json"))
452        );
453        assert_eq!(
454            response.into_body(), b"{\"code\":\"permission_denied\",\"msg\":\"Thou shall not pass\",\"meta\":{\"target\":\"Balrog\"}}"
455        );
456        Ok(())
457    }
458
459    #[cfg(feature = "http")]
460    #[test]
461    fn test_from_valid_response() -> Result<(), Box<dyn Error>> {
462        let response = http::Response::builder()
463            .header(http::header::CONTENT_TYPE, "application/json")
464            .body("{\"code\":\"permission_denied\",\"msg\":\"Thou shall not pass\",\"meta\":{\"target\":\"Balrog\"}}")?;
465        assert_eq!(
466            TwirpError::from(response),
467            TwirpError::permission_denied("Thou shall not pass").with_meta("target", "Balrog")
468        );
469        Ok(())
470    }
471
472    #[cfg(feature = "http")]
473    #[test]
474    fn test_from_plain_response() -> Result<(), Box<dyn Error>> {
475        let response = http::Response::builder()
476            .status(http::StatusCode::FORBIDDEN)
477            .body("Thou shall not pass")?;
478        assert_eq!(
479            TwirpError::from(response),
480            TwirpError::permission_denied("Thou shall not pass")
481        );
482        Ok(())
483    }
484
485    #[cfg(feature = "tonic-014")]
486    #[test]
487    fn test_from_tonic_014_status_simple() {
488        assert_eq!(
489            TwirpError::from(tonic_014::Status::not_found("Not found")),
490            TwirpError::not_found("Not found")
491        );
492    }
493
494    #[cfg(feature = "tonic-014")]
495    #[test]
496    fn test_to_tonic_014_status_simple() {
497        let error = TwirpError::not_found("Not found");
498        let status = tonic_014::Status::from(error);
499        assert_eq!(status.code(), tonic_014::Code::NotFound);
500        assert_eq!(status.message(), "Not found");
501    }
502
503    #[cfg(feature = "tonic-014")]
504    #[test]
505    fn test_from_to_tonic_014_status_roundtrip() {
506        let status = tonic_014::Status::with_details(
507            tonic_014::Code::NotFound,
508            "Not found",
509            b"some_dummy_details".to_vec().into(),
510        );
511        let new_status = tonic_014::Status::from(TwirpError::from(status.clone()));
512        assert_eq!(status.code(), new_status.code());
513        assert_eq!(status.message(), new_status.message());
514        assert_eq!(status.details(), new_status.details());
515    }
516}