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