toolkit_contract/runtime/
transport_error.rs1#[cfg(feature = "canonical-errors")]
10use toolkit_canonical_errors::Problem;
11
12#[derive(Debug, thiserror::Error)]
14#[non_exhaustive]
15pub enum TransportError {
16 #[cfg(feature = "canonical-errors")]
18 #[error("server returned problem: {} ({})", .problem.title, .problem.status.map_or_else(|| "unknown".to_owned(), |s| s.to_string()))]
19 Problem {
20 problem: Box<Problem>,
26 retry_after: Option<std::time::Duration>,
31 },
32
33 #[error("HTTP {status}: {body}")]
35 HttpStatus {
36 status: u16,
38 body: String,
40 retry_after: Option<std::time::Duration>,
44 },
45
46 #[cfg(feature = "grpc-client")]
50 #[error("gRPC {code:?}: {message}")]
51 Grpc {
52 code: tonic::Code,
54 message: String,
56 },
57
58 #[error("network error: {0}")]
60 Network(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
61
62 #[error("timeout after {0:?}")]
64 Timeout(std::time::Duration),
65
66 #[error("serialization error: {0}")]
68 Serialization(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
69
70 #[error("SSE protocol error: {0}")]
72 Sse(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
73
74 #[error("URL build error: {0}")]
76 UrlBuild(String),
77
78 #[error("provider `{gear}` is not resolvable (not ready or no live instance)")]
84 Unresolved {
85 gear: String,
87 },
88}
89
90impl TransportError {
91 pub fn network<E>(err: E) -> Self
94 where
95 E: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
96 {
97 Self::Network(err.into())
98 }
99
100 pub fn serialization<E>(err: E) -> Self
103 where
104 E: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
105 {
106 Self::Serialization(err.into())
107 }
108
109 pub fn sse<E>(err: E) -> Self
112 where
113 E: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
114 {
115 Self::Sse(err.into())
116 }
117
118 pub fn unresolved(gear: impl Into<String>) -> Self {
120 Self::Unresolved { gear: gear.into() }
121 }
122
123 #[cfg(feature = "canonical-errors")]
126 #[must_use]
127 pub fn problem(problem: Problem) -> Self {
128 Self::Problem {
129 problem: Box::new(problem),
130 retry_after: None,
131 }
132 }
133
134 #[must_use]
140 pub fn retry_after(&self) -> Option<std::time::Duration> {
141 match self {
142 TransportError::HttpStatus { retry_after, .. } => *retry_after,
143 #[cfg(feature = "canonical-errors")]
144 TransportError::Problem { retry_after, .. } => *retry_after,
145 _ => None,
146 }
147 }
148
149 #[must_use]
153 pub fn is_transient(&self) -> bool {
154 match self {
155 TransportError::Network(_)
156 | TransportError::Timeout(_)
157 | TransportError::Sse(_)
158 | TransportError::Unresolved { .. } => true,
159 TransportError::HttpStatus { status, .. } => is_retryable_status(*status),
160 #[cfg(feature = "canonical-errors")]
161 TransportError::Problem { problem, .. } => {
162 problem.status.is_some_and(is_retryable_status)
163 }
164 #[cfg(feature = "grpc-client")]
165 TransportError::Grpc { code, .. } => matches!(
166 code,
167 tonic::Code::Unavailable
168 | tonic::Code::DeadlineExceeded
169 | tonic::Code::Cancelled
170 | tonic::Code::Aborted
171 | tonic::Code::ResourceExhausted
172 ),
173 TransportError::Serialization(_) | TransportError::UrlBuild(_) => false,
174 }
175 }
176}
177
178fn is_retryable_status(status: u16) -> bool {
179 matches!(status, 429 | 502 | 503 | 504)
184}
185
186#[cfg(test)]
187#[cfg_attr(coverage_nightly, coverage(off))]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn network_and_timeout_are_transient() {
193 assert!(TransportError::network("dns").is_transient());
194 assert!(TransportError::Timeout(std::time::Duration::from_secs(1)).is_transient());
195 }
196
197 #[test]
198 fn serialization_is_not_transient() {
199 assert!(!TransportError::serialization("bad json").is_transient());
200 assert!(!TransportError::UrlBuild("missing path param".into()).is_transient());
201 }
202
203 #[test]
204 fn unresolved_is_transient() {
205 assert!(TransportError::unresolved("billing").is_transient());
206 }
207
208 #[cfg(feature = "grpc-client")]
209 #[test]
210 fn grpc_transient_codes() {
211 for code in [
212 tonic::Code::Unavailable,
213 tonic::Code::DeadlineExceeded,
214 tonic::Code::Cancelled,
215 tonic::Code::Aborted,
216 tonic::Code::ResourceExhausted,
217 ] {
218 assert!(
219 TransportError::Grpc {
220 code,
221 message: String::new(),
222 }
223 .is_transient(),
224 "expected {code:?} to be transient"
225 );
226 }
227 for code in [
228 tonic::Code::NotFound,
229 tonic::Code::InvalidArgument,
230 tonic::Code::PermissionDenied,
231 tonic::Code::Internal,
232 ] {
233 assert!(
234 !TransportError::Grpc {
235 code,
236 message: String::new(),
237 }
238 .is_transient(),
239 "expected {code:?} not to be transient"
240 );
241 }
242 }
243
244 #[test]
245 fn five_xx_is_transient_but_4xx_mostly_is_not() {
246 assert!(
247 TransportError::HttpStatus {
248 status: 503,
249 body: String::new(),
250 retry_after: None,
251 }
252 .is_transient()
253 );
254 assert!(
255 !TransportError::HttpStatus {
256 status: 404,
257 body: String::new(),
258 retry_after: None,
259 }
260 .is_transient()
261 );
262 assert!(
263 TransportError::HttpStatus {
264 status: 429,
265 body: String::new(),
266 retry_after: None,
267 }
268 .is_transient()
269 );
270 }
271
272 #[test]
273 fn bare_500_and_408_are_not_retried() {
274 for status in [500u16, 408] {
276 assert!(
277 !TransportError::HttpStatus {
278 status,
279 body: String::new(),
280 retry_after: None,
281 }
282 .is_transient(),
283 "status {status} must not be retryable"
284 );
285 }
286 }
287
288 #[test]
289 fn retry_after_accessor_reads_http_status_field() {
290 let err = TransportError::HttpStatus {
291 status: 429,
292 body: String::new(),
293 retry_after: Some(std::time::Duration::from_secs(2)),
294 };
295 assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(2)));
296 assert_eq!(TransportError::network("x").retry_after(), None);
297 }
298}