Skip to main content

weixin_agent/util/
net_error.rs

1//! Transport-failure classification for diagnostics.
2//!
3//! Network failures all arrive as one opaque error type, which makes operator
4//! triage hard: a DNS outage and an expired certificate look identical in the
5//! logs. This module maps a failure onto a small set of operator-facing
6//! categories.
7//!
8//! The classification is a **heuristic over the error source chain** and is meant
9//! for logging only — never branch on it for control flow (retry, backoff, or
10//! failover decisions must not depend on it).
11//!
12//! Only the resulting category is ever logged. The raw error text is not, because
13//! it can carry an un-redacted URL including its query string (standards §1.3).
14
15use crate::error::Error;
16
17/// Best-effort classification of a transport-level failure, for diagnostics only.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum NetErrorKind {
21    /// Name resolution failed.
22    Dns,
23    /// TCP connect refused / unreachable / timed out.
24    Tcp,
25    /// TLS handshake or certificate failure.
26    Tls,
27    /// Client-side timeout.
28    Timeout,
29    /// Not classified.
30    Unknown,
31}
32
33impl NetErrorKind {
34    /// Short operator-facing description.
35    pub fn description(self) -> &'static str {
36        match self {
37            Self::Dns => "DNS resolution failed — check the resolver and the API host name",
38            Self::Tcp => "TCP connection failed — check network reachability and egress rules",
39            Self::Tls => "TLS handshake failed — check the certificate chain and system time",
40            Self::Timeout => "request timed out on the client side",
41            Self::Unknown => "unclassified transport failure",
42        }
43    }
44
45    /// Stable lowercase label for structured log fields.
46    pub fn as_str(self) -> &'static str {
47        match self {
48            Self::Dns => "dns",
49            Self::Tcp => "tcp",
50            Self::Tls => "tls",
51            Self::Timeout => "timeout",
52            Self::Unknown => "unknown",
53        }
54    }
55}
56
57/// Collect the `Display` text of an error and its whole source chain.
58///
59/// The result is matched against keywords and then dropped — it is never logged.
60fn source_chain_text(err: &(dyn std::error::Error + 'static)) -> String {
61    let mut text = err.to_string().to_lowercase();
62    let mut current = err.source();
63    while let Some(source) = current {
64        text.push(' ');
65        text.push_str(&source.to_string().to_lowercase());
66        current = source.source();
67    }
68    text
69}
70
71/// Classify a transport failure for logging.
72pub(crate) fn classify(err: &Error) -> NetErrorKind {
73    if let Error::Http(http_err) = err {
74        if http_err.is_timeout() {
75            return NetErrorKind::Timeout;
76        }
77    }
78    if let Error::Timeout(_) = err {
79        return NetErrorKind::Timeout;
80    }
81
82    let text = source_chain_text(err);
83    if text.contains("dns error")
84        || text.contains("failed to lookup address")
85        || text.contains("name or service not known")
86        || text.contains("nodename nor servname")
87        || text.contains("enotfound")
88    {
89        return NetErrorKind::Dns;
90    }
91    if text.contains("invalid peer certificate")
92        || text.contains("certificate")
93        || text.contains("tls")
94        || text.contains("handshake")
95    {
96        return NetErrorKind::Tls;
97    }
98    if text.contains("connection refused")
99        || text.contains("connection reset")
100        || text.contains("unreachable")
101        || text.contains("connect timed out")
102        || text.contains("econnrefused")
103    {
104        return NetErrorKind::Tcp;
105    }
106    NetErrorKind::Unknown
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    /// Wrap a message as an `Error::Io`, mirroring how transport errors surface
114    /// their cause text without performing any real network I/O.
115    fn io_error(msg: &str) -> Error {
116        Error::Io(std::io::Error::other(msg.to_owned()))
117    }
118
119    #[test]
120    fn classifies_dns_failure() {
121        assert_eq!(
122            classify(&io_error("failed to lookup address information")),
123            NetErrorKind::Dns
124        );
125        assert_eq!(
126            classify(&io_error("dns error: no records")),
127            NetErrorKind::Dns
128        );
129    }
130
131    #[test]
132    fn classifies_connection_refused_as_tcp() {
133        assert_eq!(
134            classify(&io_error(
135                "tcp connect error: Connection refused (os error 61)"
136            )),
137            NetErrorKind::Tcp
138        );
139        assert_eq!(
140            classify(&io_error("network is unreachable")),
141            NetErrorKind::Tcp
142        );
143    }
144
145    #[test]
146    fn classifies_certificate_failure_as_tls() {
147        assert_eq!(
148            classify(&io_error("invalid peer certificate: Expired")),
149            NetErrorKind::Tls
150        );
151    }
152
153    #[test]
154    fn classifies_unmatched_as_unknown() {
155        assert_eq!(
156            classify(&io_error("something odd happened")),
157            NetErrorKind::Unknown
158        );
159        // Explicit timeouts are classified regardless of their message.
160        assert_eq!(
161            classify(&Error::Timeout("waited too long".into())),
162            NetErrorKind::Timeout
163        );
164    }
165
166    #[test]
167    fn descriptions_are_non_empty() {
168        for kind in [
169            NetErrorKind::Dns,
170            NetErrorKind::Tcp,
171            NetErrorKind::Tls,
172            NetErrorKind::Timeout,
173            NetErrorKind::Unknown,
174        ] {
175            assert!(!kind.description().is_empty());
176            assert!(!kind.as_str().is_empty());
177        }
178    }
179}