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