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}