Skip to main content

xmtp_proto/traits/
error.rs

1use std::fmt::Display;
2
3use crate::{ApiEndpoint, ProtoError};
4use thiserror::Error;
5use xmtp_common::{BoxDynError, ErrorCode, RetryableError, retryable};
6
7/// Authentication failures with no credential or callback error text.
8#[derive(Clone, Copy, Debug, Error, ErrorCode)]
9pub enum AuthError {
10    /// The backend rejected the credential. Retryable if a callback can run.
11    #[error("credential rejected")]
12    CredentialRejected { retryable: bool },
13    /// The callback failed. Retryable if a callback can run.
14    #[error("auth callback failed")]
15    CallbackFailed { retryable: bool },
16    /// Authentication is locked until the cool-down ends. Not retryable.
17    #[error("auth attempts exhausted")]
18    Exhausted,
19    /// No credential was set on the handle. Not retryable.
20    #[error("auth credential missing")]
21    MissingCredential,
22}
23
24impl RetryableError for AuthError {
25    fn is_retryable(&self) -> bool {
26        match self {
27            Self::CredentialRejected { retryable } | Self::CallbackFailed { retryable } => {
28                *retryable
29            }
30            Self::Exhausted | Self::MissingCredential => false,
31        }
32    }
33}
34
35impl AuthError {
36    /// True while the lockout cool-down runs. The error is not retryable now,
37    /// but it clears when the cool-down ends, so a long-lived transport must
38    /// wait instead of shutting down. Every other variant needs the caller or
39    /// the application to act, so none of them clears on its own.
40    pub fn is_locked_out(&self) -> bool {
41        matches!(self, Self::Exhausted)
42    }
43}
44
45impl ApiClientError {
46    /// True while an authentication cool-down runs. `#[error(transparent)]`
47    /// forwards Display but not `source()`, so the inner `AuthError` cannot be
48    /// reached by walking the chain. Match the variant instead.
49    pub fn is_locked_out(&self) -> bool {
50        matches!(self, Self::Auth(auth) if auth.is_locked_out())
51    }
52}
53
54#[derive(Debug, Error, ErrorCode)]
55#[non_exhaustive]
56pub enum ApiClientError {
57    #[error(transparent)]
58    #[error_code(inherit)]
59    Auth(#[from] AuthError),
60    /// The client encountered an error.
61    #[error("api client at endpoint \"{}\" has error {}", endpoint, source)]
62    ClientWithEndpoint {
63        endpoint: String,
64        /// The client error.
65        source: NetworkError,
66    },
67    /// The transport failed. Retryability follows the source.
68    #[error("client errored {}", source)]
69    Client { source: NetworkError },
70    /// The HTTP request is invalid. Not retryable.
71    #[error(transparent)]
72    Http(#[from] http::Error),
73    /// The request body is invalid. Not retryable.
74    #[error(transparent)]
75    Body(#[from] BodyError),
76    /// The response cannot be decoded. Not retryable.
77    #[error(transparent)]
78    DecodeError(#[from] prost::DecodeError),
79    /// A protocol conversion failed. Not retryable.
80    #[error(transparent)]
81    Conversion(#[from] crate::ConversionError),
82    /// A protocol operation failed. Not retryable.
83    #[error(transparent)]
84    ProtoError(#[from] ProtoError),
85    /// The URI is invalid. Not retryable.
86    #[error(transparent)]
87    InvalidUri(#[from] http::uri::InvalidUri),
88    /// The request expired. Retryable.
89    #[error(transparent)]
90    Expired(#[from] xmtp_common::time::Expired),
91    /// A client operation failed. Retryability follows the source.
92    #[error("{0}")]
93    Other(Box<dyn RetryableError>),
94    /// A client operation failed. Not retryable.
95    #[error("{0}")]
96    OtherUnretryable(BoxDynError),
97    /// Writes are disabled. Not retryable.
98    #[error("Writes are disabled on this client.")]
99    WritesDisabled,
100}
101
102/// A lower level NetworkError, like gRPC/QUIC/HTTP/1.1 errors go here.
103/// use [`ApiClientError::new`] to construct
104// needed because of AsDynError sealed trait
105#[derive(Debug)]
106pub struct NetworkError {
107    source: Box<dyn RetryableError>,
108}
109
110impl std::error::Error for NetworkError {
111    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
112        Some(self.source.as_ref())
113    }
114}
115
116impl Display for NetworkError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        write!(f, "{}", self.source)
119    }
120}
121
122impl RetryableError for NetworkError {
123    fn is_retryable(&self) -> bool {
124        self.source.is_retryable()
125    }
126}
127
128impl NetworkError {
129    pub fn new(e: impl RetryableError + 'static) -> Self {
130        NetworkError {
131            source: Box::new(e),
132        }
133    }
134}
135
136impl ApiClientError {
137    pub fn new(endpoint: ApiEndpoint, source: impl RetryableError + 'static) -> Self {
138        Self::ClientWithEndpoint {
139            endpoint: endpoint.to_string(),
140            source: NetworkError::new(source),
141        }
142    }
143
144    /// add an endpoint to a ApiError::Client error
145    pub fn endpoint(self, endpoint: impl ToString) -> Self {
146        match self {
147            Self::Client { source } => Self::ClientWithEndpoint {
148                source,
149                endpoint: endpoint.to_string(),
150            },
151            v => v,
152        }
153    }
154
155    pub fn client(client: impl RetryableError + 'static) -> Self {
156        Self::Client {
157            source: NetworkError::new(client),
158        }
159    }
160
161    /// Try to pull a [`NetworkError`] out of this error enum.
162    /// returns None if there's no match
163    pub fn network_error(&self) -> Option<&NetworkError> {
164        use ApiClientError::*;
165        match self {
166            ClientWithEndpoint { source, .. } | Client { source, .. } => Some(source),
167            _ => None,
168        }
169    }
170}
171
172impl ApiClientError {
173    pub fn other<R: RetryableError + 'static>(e: R) -> Self {
174        ApiClientError::Other(Box::new(e))
175    }
176}
177
178impl RetryableError for ApiClientError {
179    fn is_retryable(&self) -> bool {
180        use ApiClientError::*;
181        match self {
182            Client { source } => retryable!(*source),
183            ClientWithEndpoint { source, .. } => retryable!(source),
184            Auth(e) => retryable!(e),
185            Body(e) => retryable!(e),
186            Http(_) => false,
187            DecodeError(_) => false,
188            Conversion(_) => false,
189            ProtoError(_) => false,
190            InvalidUri(_) => false,
191            Expired(_) => true,
192            Other(r) => retryable!(r),
193            OtherUnretryable(_) => false,
194            WritesDisabled => false,
195        }
196    }
197}
198
199// Infallible errors by definition can never occur
200impl From<std::convert::Infallible> for ApiClientError {
201    fn from(_v: std::convert::Infallible) -> ApiClientError {
202        unreachable!("Infallible errors can never occur")
203    }
204}
205
206#[derive(Debug, Error)]
207pub enum BodyError {
208    #[error(transparent)]
209    UninitializedField(#[from] derive_builder::UninitializedFieldError),
210    #[error(transparent)]
211    Conversion(#[from] crate::ConversionError),
212}
213
214impl RetryableError for BodyError {
215    fn is_retryable(&self) -> bool {
216        false
217    }
218}
219
220/// Find a typed gRPC status through transport and wrapper error sources.
221pub fn grpc_status<'a>(
222    mut error: &'a (dyn std::error::Error + 'static),
223) -> Option<&'a tonic::Status> {
224    loop {
225        if let Some(status) = error.downcast_ref::<tonic::Status>() {
226            return Some(status);
227        }
228        error = match error.downcast_ref::<ApiClientError>() {
229            Some(ApiClientError::Other(inner)) => inner.as_ref(),
230            Some(ApiClientError::OtherUnretryable(inner)) => inner.as_ref(),
231            _ => error.source()?,
232        };
233    }
234}