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)]
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}