1use thiserror::Error;
2use xmtp_common::ErrorCode;
3use xmtp_proto::ConversionError;
4
5#[derive(Debug, Error, ErrorCode)]
6pub enum GrpcBuilderError {
7 #[error("app version required to create client")]
11 MissingAppVersion,
12 #[error("libxmtp core library version required to create client")]
16 MissingLibxmtpVersion,
17 #[error("host url required to create client")]
21 MissingHostUrl,
22 #[error(transparent)]
26 Metadata(#[from] tonic::metadata::errors::InvalidMetadataValue),
27 #[error("Invalid URI during channel creation")]
31 InvalidUri(#[from] http::uri::InvalidUri),
32 #[error(transparent)]
36 Url(#[from] url::ParseError),
37 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
41 #[error(transparent)]
42 Transport(#[from] tonic::transport::Error),
43}
44
45#[derive(Debug, Error, ErrorCode)]
46pub enum GrpcError {
47 #[error("Invalid URI during channel creation")]
51 InvalidUri(#[from] http::uri::InvalidUri),
52 #[error(transparent)]
56 Metadata(#[from] tonic::metadata::errors::InvalidMetadataValue),
57 #[error("{0}")]
61 Status(#[from] tonic::Status),
62 #[error("{0} not found/empty")]
66 NotFound(String),
67 #[error("Payload not expected")]
71 UnexpectedPayload,
72 #[error("payload is missing")]
76 MissingPayload,
77 #[error(transparent)]
78 #[error_code(inherit)]
79 Proto(#[from] xmtp_proto::ProtoError),
80 #[error(transparent)]
84 Decode(#[from] prost::DecodeError),
85 #[error("unreachable (Infallible)")]
89 Unreachable,
90 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
94 #[error(transparent)]
95 Transport(#[from] tonic::transport::Error),
96}
97
98impl From<ConversionError> for GrpcError {
99 fn from(error: ConversionError) -> Self {
100 GrpcError::NotFound(error.to_string())
101 }
102}
103
104impl GrpcError {
105 pub fn is_unimplemented(&self) -> bool {
107 matches!(self, Self::Status(status) if status.code() == tonic::Code::Unimplemented)
108 }
109}
110
111impl xmtp_common::retry::RetryableError for GrpcError {
112 fn is_retryable(&self) -> bool {
113 use tonic::Code;
114
115 match self {
116 Self::Status(status) => match status.code() {
117 Code::InvalidArgument
118 | Code::OutOfRange
119 | Code::Unimplemented
120 | Code::Aborted
121 | Code::NotFound
122 | Code::AlreadyExists
123 | Code::FailedPrecondition
124 | Code::PermissionDenied
125 | Code::Unauthenticated
126 | Code::DataLoss
127 | Code::Ok => false,
128 Code::Cancelled => is_transport_cancellation(status),
129 Code::Unavailable
130 | Code::ResourceExhausted
131 | Code::DeadlineExceeded
132 | Code::Unknown
133 | Code::Internal => true,
134 },
135 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
136 Self::Transport(_) => true,
137 Self::Proto(_)
138 | Self::InvalidUri(_)
139 | Self::Metadata(_)
140 | Self::NotFound(_)
141 | Self::UnexpectedPayload
142 | Self::MissingPayload
143 | Self::Decode(_)
144 | Self::Unreachable => false,
145 }
146 }
147}
148
149fn is_transport_cancellation(status: &tonic::Status) -> bool {
152 let mut source = std::error::Error::source(status);
153 while let Some(error) = source {
154 if error
155 .downcast_ref::<hyper::Error>()
156 .is_some_and(hyper::Error::is_canceled)
157 {
158 return true;
159 }
160 source = error.source();
161 }
162 false
163}
164
165#[cfg(test)]
166mod tests {
167 use super::GrpcError;
168 use tonic::{Code, Status};
169 use xmtp_common::RetryableError;
170
171 #[rstest::rstest]
172 #[case(Code::Ok, false)]
173 #[case(Code::Cancelled, false)]
174 #[case(Code::Unknown, true)]
175 #[case(Code::InvalidArgument, false)]
176 #[case(Code::DeadlineExceeded, true)]
177 #[case(Code::NotFound, false)]
178 #[case(Code::AlreadyExists, false)]
179 #[case(Code::PermissionDenied, false)]
180 #[case(Code::ResourceExhausted, true)]
181 #[case(Code::FailedPrecondition, false)]
182 #[case(Code::Aborted, false)]
183 #[case(Code::OutOfRange, false)]
184 #[case(Code::Unimplemented, false)]
185 #[case(Code::Internal, true)]
186 #[case(Code::Unavailable, true)]
187 #[case(Code::DataLoss, false)]
188 #[case(Code::Unauthenticated, false)]
189 #[xmtp_common::test(unwrap_try = true)]
190 async fn retry_by_status_code(#[case] code: Code, #[case] retryable: bool) {
191 for message in ["", "UNAVAILABLE", "INVALID_ARGUMENT", "request too large"] {
192 let error = GrpcError::Status(Status::new(code, message));
193 assert_eq!(error.is_retryable(), retryable, "{code:?}: {message}");
194 }
195 }
196
197 #[xmtp_common::test(unwrap_try = true)]
198 fn explicit_rpc_cancellation_is_not_retryable() {
199 use xmtp_proto::api::{ApiClientError, NetworkError, grpc_status};
200
201 for message in ["", "operation was canceled", "connection closed"] {
202 let error = NetworkError::new(ApiClientError::client(GrpcError::Status(
203 Status::cancelled(message),
204 )));
205 assert_eq!(grpc_status(&error)?.code(), Code::Cancelled);
206 assert!(!error.is_retryable());
207 }
208 let mut status = Status::cancelled("operation was canceled");
209 status.set_source(std::sync::Arc::new(std::io::Error::other(
210 "connection closed",
211 )));
212 assert!(!GrpcError::Status(status).is_retryable());
213 }
214
215 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
216 #[xmtp_common::test(unwrap_try = true)]
217 async fn cancelled_hyper_request_is_retryable_through_tonic_and_client_wrappers() {
218 use http_body_util::Empty;
219 use hyper_util::rt::TokioIo;
220 use prost::bytes::Bytes;
221 use xmtp_common::time::{Duration, timeout};
222 use xmtp_proto::api::{ApiClientError, NetworkError, grpc_status};
223
224 type Body = Empty<Bytes>;
225 for wrapped in [false, true] {
226 let (io, _peer) = tokio::io::duplex(64);
227 let (mut sender, connection) =
228 hyper::client::conn::http1::handshake::<_, Body>(TokioIo::new(io)).await?;
229 drop(connection);
230 let cancelled = sender
231 .send_request(http::Request::new(Body::new()))
232 .await
233 .expect_err("the dropped connection cannot send the request");
234 assert!(cancelled.is_canceled());
235
236 let status = if wrapped {
237 let mut cancelled = Some(cancelled);
238 let connector = tower::service_fn(move |_| {
239 std::future::ready(Err::<TokioIo<tokio::io::DuplexStream>, _>(
240 cancelled.take().expect("one connection attempt"),
241 ))
242 });
243 let endpoint = tonic::transport::Endpoint::from_static("http://unused.invalid");
244 let transport = timeout(
245 Duration::from_secs(5),
246 endpoint.connect_with_connector(connector),
247 )
248 .await?
249 .expect_err("the connector returns the cancelled request");
250 let mut status = Status::cancelled("retained transport cause");
252 status.set_source(std::sync::Arc::new(transport));
253 status
254 } else {
255 Status::from_error(Box::new(cancelled))
256 };
257 assert_eq!(status.code(), Code::Cancelled);
258 let error = GrpcError::Status(status);
259 assert!(error.is_retryable());
260 let error = ApiClientError::client(error);
261 assert!(error.is_retryable());
262 let error = NetworkError::new(error);
263 assert_eq!(grpc_status(&error)?.code(), Code::Cancelled);
264 assert!(error.is_retryable());
265 }
266 }
267}
268
269#[cfg(test)]
270mod status_sources {
271 use super::GrpcError;
272 use xmtp_proto::api::{ApiClientError, grpc_status};
273
274 #[xmtp_common::test(unwrap_try = true)]
275 fn typed_status_survives_client_error_wrappers() {
276 let error =
277 ApiClientError::client(GrpcError::Status(tonic::Status::aborted("unchanged text")));
278 assert_eq!(grpc_status(&error)?.code(), tonic::Code::Aborted);
279 let error = ApiClientError::other(GrpcError::Status(tonic::Status::out_of_range(
280 "unchanged text",
281 )));
282 assert_eq!(grpc_status(&error)?.code(), tonic::Code::OutOfRange);
283 }
284}