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    /// 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, .. } => {
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    // PRD §5.7 retryable set: throttling + gateway/upstream transient failures.
180    // Deliberately excludes 408 and 500 — a bare 500 is often a deterministic
181    // server-side failure and blindly retrying it (especially a write) risks
182    // duplicate side effects.
183    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        // PRD §5.7: 500 and 408 are deliberately excluded from the retryable set.
275        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}