Skip to main content

rama_net/client/
error.rs

1use core::{convert::Infallible, fmt};
2
3use rama_core::{
4    error::{BoxError, ErrorExt as _, extra::OpaqueError},
5    telemetry::tracing,
6};
7
8/// The architectural domain in which establishing a client connection failed.
9///
10/// Domains describe the role a protocol or component plays in a connector stack
11/// rather than assigning protocols to fixed OSI layers. For example, TLS to a
12/// proxy is part of [`Transport`](Self::Transport), while TLS to the origin is
13/// part of [`Application`](Self::Application).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum ConnectionErrorDomain {
17    /// Establishing the usable transport path selected for the connection.
18    ///
19    /// This is the route-dependent domain. A route planner can generally use
20    /// it as the signal that trying the next route may produce a different
21    /// outcome.
22    Transport,
23    /// Establishing the end-to-end connection over the selected transport path.
24    ///
25    /// This is not limited to a strict OSI application-layer protocol. It also
26    /// includes protocols and handshakes between the transport path and the final
27    /// application protocol, such as TLS to the origin. Which domain a protocol
28    /// belongs to depends on its role: TLS to a proxy is transport establishment,
29    /// while TLS to the origin is end-to-end application establishment.
30    /// A different transport route should not normally be tried for these
31    /// failures because the selected route was already usable.
32    Application,
33    /// Client-local connection acquisition or orchestration failed.
34    ///
35    /// These failures originate in the connector stack itself rather than in a
36    /// remote route or application peer. Examples include invalid local
37    /// configuration or input, connection-pool bookkeeping failures, exhaustion
38    /// of a local resource, and cancellation of the overall connection attempt.
39    /// These failures are not specific to a selected route.
40    Local,
41    /// The failure has not been classified. Consumers should not assume that
42    /// trying another route is safe merely because the domain is unknown.
43    Unknown,
44}
45
46impl fmt::Display for ConnectionErrorDomain {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        f.write_str(match self {
49            Self::Transport => "transport",
50            Self::Application => "application",
51            Self::Local => "local",
52            Self::Unknown => "unknown",
53        })
54    }
55}
56
57/// A protocol-independent description of what prevented connection setup.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59#[non_exhaustive]
60pub enum ConnectionErrorKind {
61    /// An endpoint, route, or required resource was unavailable.
62    Unavailable,
63    /// Connection setup exceeded its allowed duration.
64    Timeout,
65    /// A peer explicitly rejected the connection operation.
66    Rejected,
67    /// Connection setup requires authentication or authentication failed.
68    Authentication,
69    /// A protocol handshake or negotiation failed.
70    Protocol,
71    /// The connection input or configuration is invalid.
72    InvalidInput,
73    /// Local connection machinery failed unexpectedly.
74    Internal,
75    /// The failure does not fit another kind.
76    Other,
77}
78
79impl fmt::Display for ConnectionErrorKind {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        f.write_str(match self {
82            Self::Unavailable => "unavailable",
83            Self::Timeout => "timeout",
84            Self::Rejected => "rejected",
85            Self::Authentication => "authentication",
86            Self::Protocol => "protocol",
87            Self::InvalidInput => "invalid-input",
88            Self::Internal => "internal",
89            Self::Other => "other",
90        })
91    }
92}
93
94/// A classified error produced while establishing a client connection.
95///
96/// The original error remains available as the source. Adding context only
97/// wraps that source and therefore preserves the classification.
98#[must_use = "a connection error should be returned or handled"]
99pub struct ConnectionError {
100    source: BoxError,
101    domain: ConnectionErrorDomain,
102    kind: ConnectionErrorKind,
103}
104
105impl ConnectionError {
106    /// Create a classified connection error.
107    pub fn new(
108        source: impl Into<BoxError>,
109        domain: ConnectionErrorDomain,
110        kind: ConnectionErrorKind,
111    ) -> Self {
112        Self {
113            source: source.into(),
114            domain,
115            kind,
116        }
117    }
118
119    /// Create an error which occurred while establishing the transport path.
120    pub fn transport(source: impl Into<BoxError>, kind: ConnectionErrorKind) -> Self {
121        Self::new(source, ConnectionErrorDomain::Transport, kind)
122    }
123
124    /// Create an error which occurred while establishing the end-to-end connection.
125    ///
126    /// This includes intermediate end-to-end protocols such as TLS to the origin,
127    /// not only the final application protocol.
128    pub fn application(source: impl Into<BoxError>, kind: ConnectionErrorKind) -> Self {
129        Self::new(source, ConnectionErrorDomain::Application, kind)
130    }
131
132    /// Create an error produced by client-local acquisition or orchestration machinery.
133    pub fn local(source: impl Into<BoxError>, kind: ConnectionErrorKind) -> Self {
134        Self::new(source, ConnectionErrorDomain::Local, kind)
135    }
136
137    /// Create an unclassified connection error.
138    pub fn unknown(source: impl Into<BoxError>) -> Self {
139        Self::new(
140            source,
141            ConnectionErrorDomain::Unknown,
142            ConnectionErrorKind::Other,
143        )
144    }
145
146    /// Return the architectural domain in which the failure occurred.
147    #[inline]
148    pub fn domain(&self) -> ConnectionErrorDomain {
149        self.domain
150    }
151
152    /// Return the protocol-independent kind of failure.
153    #[inline]
154    pub fn kind(&self) -> ConnectionErrorKind {
155        self.kind
156    }
157
158    /// Return the original error, including any attached context.
159    #[inline]
160    pub fn get_ref(&self) -> &(dyn core::error::Error + Send + Sync + 'static) {
161        self.source.as_ref()
162    }
163
164    /// Consume this error and return its boxed source.
165    #[inline]
166    pub fn into_source(self) -> BoxError {
167        self.source
168    }
169
170    /// Convert this error into a [`BoxError`] without losing its classification.
171    #[inline]
172    pub fn into_box_error(self) -> BoxError {
173        Box::new(self)
174    }
175
176    /// Convert this error into an [`OpaqueError`] without losing its classification.
177    #[inline]
178    pub fn into_opaque_error(self) -> OpaqueError {
179        self.into_box_error().into_opaque_error()
180    }
181
182    /// Add context to the source error.
183    pub fn context<M>(mut self, value: M) -> Self
184    where
185        M: fmt::Debug + fmt::Display + Send + Sync + 'static,
186    {
187        self.source = self.source.context(value);
188        self
189    }
190
191    /// Add context using [`fmt::LowerHex`] for its formatting.
192    pub fn context_hex<M>(mut self, value: M) -> Self
193    where
194        M: fmt::Debug + Send + Sync + 'static,
195    {
196        self.source = self.source.context_hex(value);
197        self
198    }
199
200    /// Add context using [`fmt::Debug`] for its display formatting.
201    pub fn context_debug<M>(mut self, value: M) -> Self
202    where
203        M: fmt::Debug + Send + Sync + 'static,
204    {
205        self.source = self.source.context_debug(value);
206        self
207    }
208
209    /// Add keyed context to the source error.
210    pub fn context_field<M>(mut self, key: &'static str, value: M) -> Self
211    where
212        M: fmt::Debug + fmt::Display + Send + Sync + 'static,
213    {
214        self.source = self.source.context_field(key, value);
215        self
216    }
217
218    /// Add a keyed string-like context value to the source error.
219    pub fn context_str_field<M>(mut self, key: &'static str, value: M) -> Self
220    where
221        M: Into<String>,
222    {
223        self.source = self.source.context_str_field(key, value);
224        self
225    }
226
227    /// Add keyed context using [`fmt::LowerHex`] for its formatting.
228    pub fn context_hex_field<M>(mut self, key: &'static str, value: M) -> Self
229    where
230        M: fmt::Debug + Send + Sync + 'static,
231    {
232        self.source = self.source.context_hex_field(key, value);
233        self
234    }
235
236    /// Add keyed context using [`fmt::Debug`] for its display formatting.
237    pub fn context_debug_field<M>(mut self, key: &'static str, value: M) -> Self
238    where
239        M: fmt::Debug + Send + Sync + 'static,
240    {
241        self.source = self.source.context_debug_field(key, value);
242        self
243    }
244
245    /// Lazily add context to the source error.
246    pub fn with_context<C, F>(mut self, create: F) -> Self
247    where
248        C: fmt::Debug + fmt::Display + Send + Sync + 'static,
249        F: FnOnce() -> C,
250    {
251        self.source = self.source.with_context(create);
252        self
253    }
254
255    /// Lazily add context using [`fmt::LowerHex`] for its formatting.
256    pub fn with_context_hex<C, F>(mut self, create: F) -> Self
257    where
258        C: fmt::Debug + Send + Sync + 'static,
259        F: FnOnce() -> C,
260    {
261        self.source = self.source.with_context_hex(create);
262        self
263    }
264
265    /// Lazily add context using [`fmt::Debug`] for its display formatting.
266    pub fn with_context_debug<C, F>(mut self, create: F) -> Self
267    where
268        C: fmt::Debug + Send + Sync + 'static,
269        F: FnOnce() -> C,
270    {
271        self.source = self.source.with_context_debug(create);
272        self
273    }
274
275    /// Lazily add keyed context to the source error.
276    pub fn with_context_field<C, F>(mut self, key: &'static str, create: F) -> Self
277    where
278        C: fmt::Debug + fmt::Display + Send + Sync + 'static,
279        F: FnOnce() -> C,
280    {
281        self.source = self.source.with_context_field(key, create);
282        self
283    }
284
285    /// Lazily add a keyed string-like context value to the source error.
286    pub fn with_context_str_field<C, F>(mut self, key: &'static str, create: F) -> Self
287    where
288        C: Into<String>,
289        F: FnOnce() -> C,
290    {
291        self.source = self.source.with_context_str_field(key, create);
292        self
293    }
294
295    /// Lazily add keyed context using [`fmt::LowerHex`] for its formatting.
296    pub fn with_context_hex_field<C, F>(mut self, key: &'static str, create: F) -> Self
297    where
298        C: fmt::Debug + Send + Sync + 'static,
299        F: FnOnce() -> C,
300    {
301        self.source = self.source.with_context_hex_field(key, create);
302        self
303    }
304
305    /// Lazily add keyed context using [`fmt::Debug`] for its display formatting.
306    pub fn with_context_debug_field<C, F>(mut self, key: &'static str, create: F) -> Self
307    where
308        C: fmt::Debug + Send + Sync + 'static,
309        F: FnOnce() -> C,
310    {
311        self.source = self.source.with_context_debug_field(key, create);
312        self
313    }
314
315    /// Capture a backtrace and attach it to the source error.
316    pub fn backtrace(mut self) -> Self {
317        self.source = self.source.backtrace();
318        self
319    }
320
321    fn classification_in_source_chain(
322        source: &(dyn core::error::Error + 'static),
323    ) -> Option<(ConnectionErrorDomain, ConnectionErrorKind)> {
324        let mut current = Some(source);
325        let mut timeout = false;
326        // Protect conversion from a malformed error implementation with a cyclic
327        // source chain. Rama's own wrappers are shallow and acyclic.
328        for _ in 0..64 {
329            let Some(error) = current else {
330                break;
331            };
332            if let Some(error) = error.downcast_ref::<Self>() {
333                return Some((error.domain, error.kind));
334            }
335            timeout |= error.is::<rama_core::layer::timeout::Elapsed>()
336                || error.is::<tokio::time::error::Elapsed>();
337            current = error.source();
338        }
339        timeout.then_some((
340            ConnectionErrorDomain::Transport,
341            ConnectionErrorKind::Timeout,
342        ))
343    }
344}
345
346impl From<BoxError> for ConnectionError {
347    fn from(source: BoxError) -> Self {
348        let source = match source.downcast::<Self>() {
349            Ok(error) => return *error,
350            Err(source) => source,
351        };
352
353        let (domain, kind) =
354            Self::classification_in_source_chain(source.as_ref()).unwrap_or_else(|| {
355                tracing::debug!(
356                    "connector error is unclassified; retry-based routing will treat it as terminal"
357                );
358                (ConnectionErrorDomain::Unknown, ConnectionErrorKind::Other)
359            });
360
361        Self {
362            source,
363            domain,
364            kind,
365        }
366    }
367}
368
369impl From<Infallible> for ConnectionError {
370    fn from(error: Infallible) -> Self {
371        match error {}
372    }
373}
374
375impl fmt::Debug for ConnectionError {
376    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
377        f.debug_struct("ConnectionError")
378            .field("domain", &self.domain)
379            .field("kind", &self.kind)
380            .field("source", &self.source)
381            .finish()
382    }
383}
384
385impl fmt::Display for ConnectionError {
386    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387        fmt::Display::fmt(&self.source, f)
388    }
389}
390
391impl core::error::Error for ConnectionError {
392    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
393        Some(self.source.as_ref())
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use rama_core::{Layer, Service, error::ErrorContext as _};
401
402    #[derive(Debug)]
403    struct TestError(&'static str);
404
405    impl fmt::Display for TestError {
406        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407            f.write_str(self.0)
408        }
409    }
410
411    impl core::error::Error for TestError {}
412
413    #[derive(Debug)]
414    struct CyclicError;
415
416    impl fmt::Display for CyclicError {
417        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
418            f.write_str("cyclic error")
419        }
420    }
421
422    impl core::error::Error for CyclicError {
423        fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
424            Some(self)
425        }
426    }
427
428    fn unavailable_transport_error() -> ConnectionError {
429        ConnectionError::transport(
430            TestError("connect failed"),
431            ConnectionErrorKind::Unavailable,
432        )
433    }
434
435    fn assert_unavailable_transport(error: &ConnectionError) {
436        assert_eq!(error.domain(), ConnectionErrorDomain::Transport);
437        assert_eq!(error.kind(), ConnectionErrorKind::Unavailable);
438    }
439
440    #[test]
441    fn display_preserves_source_message() {
442        let error = unavailable_transport_error();
443        assert_eq!(error.to_string(), "connect failed");
444        assert!(error.get_ref().is::<TestError>());
445    }
446
447    #[test]
448    fn direct_box_roundtrip_preserves_classification() {
449        let error = ConnectionError::from(unavailable_transport_error().into_box_error());
450        assert_unavailable_transport(&error);
451        assert!(error.get_ref().is::<TestError>());
452    }
453
454    #[test]
455    fn contextual_box_roundtrip_preserves_classification() {
456        let boxed = unavailable_transport_error()
457            .into_box_error()
458            .context("dial selected endpoint")
459            .context_field("attempt", 2);
460
461        let error = ConnectionError::from(boxed);
462        assert_unavailable_transport(&error);
463        let message = error.to_string();
464        assert!(message.contains("dial selected endpoint"), "{message}");
465        assert!(message.contains("attempt=\"2\""), "{message}");
466    }
467
468    #[tokio::test(start_paused = true)]
469    async fn timeout_layer_error_is_classified_as_transport_timeout() {
470        let source =
471            rama_core::layer::timeout::TimeoutLayer::new(core::time::Duration::from_secs(1));
472        let error = source
473            .into_layer(rama_core::service::service_fn(async |(): ()| {
474                core::future::pending::<Result<(), Infallible>>().await
475            }))
476            .serve(())
477            .await
478            .unwrap_err();
479
480        let error = ConnectionError::from(error.context("connector attempt"));
481        assert_eq!(error.domain(), ConnectionErrorDomain::Transport);
482        assert_eq!(error.kind(), ConnectionErrorKind::Timeout);
483    }
484
485    #[test]
486    fn result_context_roundtrip_preserves_classification() {
487        fn contextualized() -> Result<(), ConnectionError> {
488            let result: Result<(), ConnectionError> = Err(unavailable_transport_error());
489            result.context("establish connection")?;
490            Ok(())
491        }
492
493        let error = contextualized().unwrap_err();
494        assert_unavailable_transport(&error);
495        assert!(error.to_string().contains("establish connection"));
496    }
497
498    #[test]
499    fn inherent_context_api_preserves_classification() {
500        let error = unavailable_transport_error()
501            .context("context")
502            .context_hex(255)
503            .context_debug(Some("debug"))
504            .context_field("field", 1)
505            .context_str_field("str", "value")
506            .context_hex_field("hex", 255)
507            .context_debug_field("debug", Some(2))
508            .with_context(|| "lazy-context")
509            .with_context_hex(|| 16)
510            .with_context_debug(|| Some("lazy-debug"))
511            .with_context_field("lazy-field", || 3)
512            .with_context_str_field("lazy-str", || "lazy-value")
513            .with_context_hex_field("lazy-hex", || 32)
514            .with_context_debug_field("lazy-debug-field", || Some(4));
515
516        assert_unavailable_transport(&error);
517        let message = error.to_string();
518        for expected in [
519            "context",
520            "field=\"1\"",
521            "str=\"value\"",
522            "lazy-context",
523            "lazy-field=\"3\"",
524            "lazy-str=\"lazy-value\"",
525        ] {
526            assert!(
527                message.contains(expected),
528                "missing {expected:?} in {message}"
529            );
530        }
531    }
532
533    #[test]
534    fn unknown_box_error_gets_safe_classification() {
535        let source: BoxError = Box::new(TestError("legacy error"));
536        let error = ConnectionError::from(source);
537
538        assert_eq!(error.domain(), ConnectionErrorDomain::Unknown);
539        assert_eq!(error.kind(), ConnectionErrorKind::Other);
540        assert_eq!(error.to_string(), "legacy error");
541    }
542
543    #[test]
544    fn cyclic_source_chain_gets_safe_classification() {
545        let source: BoxError = Box::new(CyclicError);
546        let error = ConnectionError::from(source);
547
548        assert_eq!(error.domain(), ConnectionErrorDomain::Unknown);
549        assert_eq!(error.kind(), ConnectionErrorKind::Other);
550        assert_eq!(error.to_string(), "cyclic error");
551    }
552
553    #[test]
554    fn backtrace_and_opaque_roundtrip_preserve_classification() {
555        let opaque = unavailable_transport_error()
556            .backtrace()
557            .into_opaque_error();
558        let error = ConnectionError::from(opaque.into_box_error());
559
560        assert_unavailable_transport(&error);
561        assert_eq!(error.to_string(), "connect failed");
562    }
563}