Skip to main content

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}