toolkit_contract/runtime/transport_error.rs
1//! `TransportError` — uniform transport-layer error surfaced by generated
2//! REST clients.
3//!
4//! The wire envelope is `toolkit_canonical_errors::Problem` (RFC 9457) when
5//! the peer participates in the canonical error system. Older peers may
6//! return raw HTTP status codes without a Problem body — those land in
7//! [`TransportError::HttpStatus`].
8
9#[cfg(feature = "canonical-errors")]
10use toolkit_canonical_errors::Problem;
11
12/// Errors produced by the generated REST client transport layer.
13#[derive(Debug, thiserror::Error)]
14#[non_exhaustive]
15pub enum TransportError {
16 /// The server returned a structured RFC 9457 `Problem` payload.
17 #[cfg(feature = "canonical-errors")]
18 #[error("server returned problem: {} ({})", .problem.title, .problem.status)]
19 Problem {
20 /// The structured RFC 9457 problem payload. Boxed: `Problem` itself is
21 /// large enough (several `String`/`Value` fields) that an unboxed copy
22 /// here would make `TransportError` — and therefore every
23 /// `Result<_, TransportError>` return type across the crate — bloat
24 /// past clippy's `result_large_err` threshold.
25 problem: Box<Problem>,
26 /// Server-advised minimum wait before retry, parsed from the
27 /// `Retry-After` response header (delta-seconds), when present. Carried
28 /// alongside the `Problem` so in-mesh peers that speak canonical errors
29 /// still get their advised backoff honored by the retry loop.
30 retry_after: Option<std::time::Duration>,
31 },
32
33 /// The server returned a non-success status with a non-Problem body.
34 #[error("HTTP {status}: {body}")]
35 HttpStatus {
36 /// Numeric HTTP status code.
37 status: u16,
38 /// Body excerpt suitable for diagnostics. Truncated at the call site.
39 body: String,
40 /// Server-advised minimum wait before retry, parsed from the
41 /// `Retry-After` response header (delta-seconds), when present. The
42 /// retry loop honors this in preference to computed backoff.
43 retry_after: Option<std::time::Duration>,
44 },
45
46 /// The gRPC server returned a non-OK status. Preserves the original
47 /// `tonic::Code` so callers can map it back to canonical categories
48 /// without losing information through an HTTP-status detour.
49 #[cfg(feature = "grpc-client")]
50 #[error("gRPC {code:?}: {message}")]
51 Grpc {
52 /// The raw gRPC status code as returned by the server.
53 code: tonic::Code,
54 /// Human-readable detail copied from `tonic::Status::message`.
55 message: String,
56 },
57
58 /// Low-level network failure (DNS, connect, TLS, mid-flight reset).
59 #[error("network error: {0}")]
60 Network(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
61
62 /// The total deadline elapsed before the response was complete.
63 #[error("timeout after {0:?}")]
64 Timeout(std::time::Duration),
65
66 /// Request or response (de)serialization failure.
67 #[error("serialization error: {0}")]
68 Serialization(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
69
70 /// Server-Sent Events stream error (frame parse, malformed event, etc.).
71 #[error("SSE protocol error: {0}")]
72 Sse(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
73
74 /// URL construction error (missing path parameter, invalid template).
75 #[error("URL build error: {0}")]
76 UrlBuild(String),
77
78 /// The providing gear could not be resolved to a live endpoint via the
79 /// service directory: it has not registered yet, or every instance was
80 /// evicted (e.g., the provider pod went away). Treated as transient — the
81 /// directory-resolving client re-resolves on the next call and recovers
82 /// once a live instance reappears.
83 #[error("provider `{gear}` is not resolvable (not ready or no live instance)")]
84 Unresolved {
85 /// Logical gear name that could not be resolved to an endpoint.
86 gear: String,
87 },
88}
89
90impl TransportError {
91 /// Convenience constructor for [`TransportError::Network`] from any
92 /// boxable error. Preserves the source via `Error::source()`.
93 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 /// Convenience constructor for [`TransportError::Serialization`] from any
101 /// boxable error. Preserves the source via `Error::source()`.
102 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 /// Convenience constructor for [`TransportError::Sse`] from any boxable
110 /// error. Preserves the source via `Error::source()`.
111 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 /// Convenience constructor for [`TransportError::Unresolved`].
119 pub fn unresolved(gear: impl Into<String>) -> Self {
120 Self::Unresolved { gear: gear.into() }
121 }
122
123 /// Convenience constructor for [`TransportError::Problem`] with no
124 /// server-advised `Retry-After`.
125 #[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 /// Server-advised retry delay (`Retry-After`), when the error carries one.
135 ///
136 /// Carried by both [`TransportError::HttpStatus`] (non-`Problem` peers) and
137 /// [`TransportError::Problem`] (canonical-error peers), parsed from the
138 /// response header. Returns `None` for all other error classes.
139 #[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 /// Whether this error class is generally safe to retry without a higher-level
150 /// idempotency strategy. Used by [`crate::runtime::retry`] when a method is
151 /// declared `#[retryable]`.
152 #[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, .. } => is_retryable_status(problem.status),
162 #[cfg(feature = "grpc-client")]
163 TransportError::Grpc { code, .. } => matches!(
164 code,
165 tonic::Code::Unavailable
166 | tonic::Code::DeadlineExceeded
167 | tonic::Code::Cancelled
168 | tonic::Code::Aborted
169 | tonic::Code::ResourceExhausted
170 ),
171 TransportError::Serialization(_) | TransportError::UrlBuild(_) => false,
172 }
173 }
174}
175
176fn is_retryable_status(status: u16) -> bool {
177 // PRD §5.7 retryable set: throttling + gateway/upstream transient failures.
178 // Deliberately excludes 408 and 500 — a bare 500 is often a deterministic
179 // server-side failure and blindly retrying it (especially a write) risks
180 // duplicate side effects.
181 matches!(status, 429 | 502 | 503 | 504)
182}
183
184#[cfg(test)]
185#[cfg_attr(coverage_nightly, coverage(off))]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn network_and_timeout_are_transient() {
191 assert!(TransportError::network("dns").is_transient());
192 assert!(TransportError::Timeout(std::time::Duration::from_secs(1)).is_transient());
193 }
194
195 #[test]
196 fn serialization_is_not_transient() {
197 assert!(!TransportError::serialization("bad json").is_transient());
198 assert!(!TransportError::UrlBuild("missing path param".into()).is_transient());
199 }
200
201 #[test]
202 fn unresolved_is_transient() {
203 assert!(TransportError::unresolved("billing").is_transient());
204 }
205
206 #[cfg(feature = "grpc-client")]
207 #[test]
208 fn grpc_transient_codes() {
209 for code in [
210 tonic::Code::Unavailable,
211 tonic::Code::DeadlineExceeded,
212 tonic::Code::Cancelled,
213 tonic::Code::Aborted,
214 tonic::Code::ResourceExhausted,
215 ] {
216 assert!(
217 TransportError::Grpc {
218 code,
219 message: String::new(),
220 }
221 .is_transient(),
222 "expected {code:?} to be transient"
223 );
224 }
225 for code in [
226 tonic::Code::NotFound,
227 tonic::Code::InvalidArgument,
228 tonic::Code::PermissionDenied,
229 tonic::Code::Internal,
230 ] {
231 assert!(
232 !TransportError::Grpc {
233 code,
234 message: String::new(),
235 }
236 .is_transient(),
237 "expected {code:?} not to be transient"
238 );
239 }
240 }
241
242 #[test]
243 fn five_xx_is_transient_but_4xx_mostly_is_not() {
244 assert!(
245 TransportError::HttpStatus {
246 status: 503,
247 body: String::new(),
248 retry_after: None,
249 }
250 .is_transient()
251 );
252 assert!(
253 !TransportError::HttpStatus {
254 status: 404,
255 body: String::new(),
256 retry_after: None,
257 }
258 .is_transient()
259 );
260 assert!(
261 TransportError::HttpStatus {
262 status: 429,
263 body: String::new(),
264 retry_after: None,
265 }
266 .is_transient()
267 );
268 }
269
270 #[test]
271 fn bare_500_and_408_are_not_retried() {
272 // PRD §5.7: 500 and 408 are deliberately excluded from the retryable set.
273 for status in [500u16, 408] {
274 assert!(
275 !TransportError::HttpStatus {
276 status,
277 body: String::new(),
278 retry_after: None,
279 }
280 .is_transient(),
281 "status {status} must not be retryable"
282 );
283 }
284 }
285
286 #[test]
287 fn retry_after_accessor_reads_http_status_field() {
288 let err = TransportError::HttpStatus {
289 status: 429,
290 body: String::new(),
291 retry_after: Some(std::time::Duration::from_secs(2)),
292 };
293 assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(2)));
294 assert_eq!(TransportError::network("x").retry_after(), None);
295 }
296}