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.map_or_else(|| "unknown".to_owned(), |s| s.to_string()))]
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 /// Streaming framing-protocol error: the peer's bytes do not conform to
71 /// the wire framing in use — a malformed SSE frame, a bad multipart
72 /// delimiter or part header, a part length that overruns its delimiter, an
73 /// accumulation guard trip.
74 ///
75 /// Distinct from [`TransportError::Serialization`], which is a
76 /// well-framed frame or part whose *payload* would not decode.
77 ///
78 /// Replaces an earlier SSE-only variant: naming the framing is what keeps
79 /// a fault attributable once more than one framing exists, so there is
80 /// deliberately no framing-specific variant to reach for instead.
81 #[error("{} framing error: {source}", framing.media_type())]
82 Framing {
83 /// Which wire framing produced the fault.
84 framing: crate::ir::binding::StreamFraming,
85 /// Underlying cause.
86 #[source]
87 source: Box<dyn std::error::Error + Send + Sync + 'static>,
88 },
89
90 /// URL construction error (missing path parameter, invalid template).
91 #[error("URL build error: {0}")]
92 UrlBuild(String),
93
94 /// The providing gear could not be resolved to a live endpoint via the
95 /// service directory: it has not registered yet, or every instance was
96 /// evicted (e.g., the provider pod went away). Treated as transient — the
97 /// directory-resolving client re-resolves on the next call and recovers
98 /// once a live instance reappears.
99 #[error("provider `{gear}` is not resolvable (not ready or no live instance)")]
100 Unresolved {
101 /// Logical gear name that could not be resolved to an endpoint.
102 gear: String,
103 },
104}
105
106impl TransportError {
107 /// Convenience constructor for [`TransportError::Network`] from any
108 /// boxable error. Preserves the source via `Error::source()`.
109 pub fn network<E>(err: E) -> Self
110 where
111 E: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
112 {
113 Self::Network(err.into())
114 }
115
116 /// Convenience constructor for [`TransportError::Serialization`] from any
117 /// boxable error. Preserves the source via `Error::source()`.
118 pub fn serialization<E>(err: E) -> Self
119 where
120 E: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
121 {
122 Self::Serialization(err.into())
123 }
124
125 /// Convenience constructor for [`TransportError::Framing`] from any
126 /// boxable error. Preserves the source via `Error::source()`.
127 pub fn framing<E>(framing: crate::ir::binding::StreamFraming, err: E) -> Self
128 where
129 E: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
130 {
131 Self::Framing {
132 framing,
133 source: err.into(),
134 }
135 }
136
137 /// Convenience constructor for [`TransportError::Unresolved`].
138 pub fn unresolved(gear: impl Into<String>) -> Self {
139 Self::Unresolved { gear: gear.into() }
140 }
141
142 /// Convenience constructor for [`TransportError::Problem`] with no
143 /// server-advised `Retry-After`.
144 #[cfg(feature = "canonical-errors")]
145 #[must_use]
146 pub fn problem(problem: Problem) -> Self {
147 Self::Problem {
148 problem: Box::new(problem),
149 retry_after: None,
150 }
151 }
152
153 /// Server-advised retry delay (`Retry-After`), when the error carries one.
154 ///
155 /// Carried by both [`TransportError::HttpStatus`] (non-`Problem` peers) and
156 /// [`TransportError::Problem`] (canonical-error peers), parsed from the
157 /// response header. Returns `None` for all other error classes.
158 #[must_use]
159 pub fn retry_after(&self) -> Option<std::time::Duration> {
160 match self {
161 TransportError::HttpStatus { retry_after, .. } => *retry_after,
162 #[cfg(feature = "canonical-errors")]
163 TransportError::Problem { retry_after, .. } => *retry_after,
164 _ => None,
165 }
166 }
167
168 /// Whether this error class is generally safe to retry without a higher-level
169 /// idempotency strategy. Used by [`crate::runtime::retry`] when a method is
170 /// declared `#[retryable]`.
171 #[must_use]
172 pub fn is_transient(&self) -> bool {
173 match self {
174 // `Framing` is transient deliberately: that classification is
175 // what makes a mid-stream framing fault reconnect-eligible.
176 TransportError::Network(_)
177 | TransportError::Timeout(_)
178 | TransportError::Framing { .. }
179 | TransportError::Unresolved { .. } => true,
180 TransportError::HttpStatus { status, .. } => is_retryable_status(*status),
181 #[cfg(feature = "canonical-errors")]
182 TransportError::Problem { problem, .. } => {
183 problem.status.is_some_and(is_retryable_status)
184 }
185 #[cfg(feature = "grpc-client")]
186 TransportError::Grpc { code, .. } => matches!(
187 code,
188 tonic::Code::Unavailable
189 | tonic::Code::DeadlineExceeded
190 | tonic::Code::Cancelled
191 | tonic::Code::Aborted
192 | tonic::Code::ResourceExhausted
193 ),
194 TransportError::Serialization(_) | TransportError::UrlBuild(_) => false,
195 }
196 }
197}
198
199fn is_retryable_status(status: u16) -> bool {
200 // PRD §5.7 retryable set: throttling + gateway/upstream transient failures.
201 // Deliberately excludes 408 and 500 — a bare 500 is often a deterministic
202 // server-side failure and blindly retrying it (especially a write) risks
203 // duplicate side effects.
204 matches!(status, 429 | 502 | 503 | 504)
205}
206
207#[cfg(test)]
208#[cfg_attr(coverage_nightly, coverage(off))]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn network_and_timeout_are_transient() {
214 assert!(TransportError::network("dns").is_transient());
215 assert!(TransportError::Timeout(std::time::Duration::from_secs(1)).is_transient());
216 }
217
218 #[test]
219 fn serialization_is_not_transient() {
220 assert!(!TransportError::serialization("bad json").is_transient());
221 assert!(!TransportError::UrlBuild("missing path param".into()).is_transient());
222 }
223
224 #[test]
225 fn unresolved_is_transient() {
226 assert!(TransportError::unresolved("billing").is_transient());
227 }
228
229 #[test]
230 fn framing_is_transient_for_every_framing() {
231 // Q9: a framing fault is transient on purpose — that classification is
232 // what makes it reconnect-eligible, and it must not depend on which
233 // framing faulted.
234 for framing in [
235 crate::ir::binding::StreamFraming::ServerSentEvents,
236 crate::ir::binding::StreamFraming::MultipartMixed,
237 ] {
238 assert!(
239 TransportError::framing(framing, "bad frame").is_transient(),
240 "expected {framing:?} framing errors to be transient"
241 );
242 }
243 }
244
245 #[test]
246 fn framing_display_names_the_media_type() {
247 assert_eq!(
248 TransportError::framing(
249 crate::ir::binding::StreamFraming::MultipartMixed,
250 "bad delimiter",
251 )
252 .to_string(),
253 "multipart/mixed framing error: bad delimiter"
254 );
255 assert_eq!(
256 TransportError::framing(
257 crate::ir::binding::StreamFraming::ServerSentEvents,
258 "bad frame",
259 )
260 .to_string(),
261 "text/event-stream framing error: bad frame"
262 );
263 }
264
265 #[cfg(feature = "grpc-client")]
266 #[test]
267 fn grpc_transient_codes() {
268 for code in [
269 tonic::Code::Unavailable,
270 tonic::Code::DeadlineExceeded,
271 tonic::Code::Cancelled,
272 tonic::Code::Aborted,
273 tonic::Code::ResourceExhausted,
274 ] {
275 assert!(
276 TransportError::Grpc {
277 code,
278 message: String::new(),
279 }
280 .is_transient(),
281 "expected {code:?} to be transient"
282 );
283 }
284 for code in [
285 tonic::Code::NotFound,
286 tonic::Code::InvalidArgument,
287 tonic::Code::PermissionDenied,
288 tonic::Code::Internal,
289 ] {
290 assert!(
291 !TransportError::Grpc {
292 code,
293 message: String::new(),
294 }
295 .is_transient(),
296 "expected {code:?} not to be transient"
297 );
298 }
299 }
300
301 #[test]
302 fn five_xx_is_transient_but_4xx_mostly_is_not() {
303 assert!(
304 TransportError::HttpStatus {
305 status: 503,
306 body: String::new(),
307 retry_after: None,
308 }
309 .is_transient()
310 );
311 assert!(
312 !TransportError::HttpStatus {
313 status: 404,
314 body: String::new(),
315 retry_after: None,
316 }
317 .is_transient()
318 );
319 assert!(
320 TransportError::HttpStatus {
321 status: 429,
322 body: String::new(),
323 retry_after: None,
324 }
325 .is_transient()
326 );
327 }
328
329 #[test]
330 fn bare_500_and_408_are_not_retried() {
331 // PRD §5.7: 500 and 408 are deliberately excluded from the retryable set.
332 for status in [500u16, 408] {
333 assert!(
334 !TransportError::HttpStatus {
335 status,
336 body: String::new(),
337 retry_after: None,
338 }
339 .is_transient(),
340 "status {status} must not be retryable"
341 );
342 }
343 }
344
345 #[test]
346 fn retry_after_accessor_reads_http_status_field() {
347 let err = TransportError::HttpStatus {
348 status: 429,
349 body: String::new(),
350 retry_after: Some(std::time::Duration::from_secs(2)),
351 };
352 assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(2)));
353 assert_eq!(TransportError::network("x").retry_after(), None);
354 }
355}