Skip to main content

ilink_hub/
error.rs

1use thiserror::Error;
2
3#[derive(Debug, Error)]
4pub enum HubError {
5    /// HTTP-level failure communicating with the iLink upstream.
6    #[error("iLink upstream HTTP error {status}: {msg}")]
7    UpstreamHttp { status: u16, msg: String },
8
9    /// Response from the iLink upstream could not be parsed.
10    #[error("iLink upstream parse error: {0}")]
11    UpstreamParse(String),
12
13    /// Generic upstream error — kept for third-party MessageQueue implementations
14    /// that may not have a more specific variant available.
15    #[error("iLink upstream error: {0}")]
16    Upstream(String),
17
18    #[error("client not found: {0}")]
19    ClientNotFound(String),
20
21    #[error("invalid token")]
22    InvalidToken,
23
24    #[error("session not found: {0}")]
25    SessionNotFound(String),
26
27    #[error("configuration error: {0}")]
28    Config(String),
29
30    #[error("I/O error: {0}")]
31    Io(#[from] std::io::Error),
32
33    #[error("database error: {0}")]
34    Database(#[from] sqlx::Error),
35
36    #[error("operation timed out")]
37    Timeout,
38
39    /// Queue backend operation failed.
40    #[error("queue backend error: {0}")]
41    QueueBackend(String),
42}
43
44impl From<anyhow::Error> for HubError {
45    fn from(e: anyhow::Error) -> Self {
46        // First, try to recover a `HubError` that was wrapped at an upstream
47        // call site via `anyhow::Error::new(HubError::UpstreamHttp { ... })` or
48        // `HubError::UpstreamParse(...)`. This lets N-06 specific variants
49        // survive a round-trip through `anyhow::Result` and still be
50        // pattern-matched by downstream consumers (e.g. to distinguish a
51        // transient HTTP 503 from a malformed JSON body).
52        //
53        // `downcast()` consumes `e` and returns the inner `T` on success, or
54        // hands `e` back on failure, so the fallback chain stays in scope.
55        match e.downcast::<HubError>() {
56            Ok(hub_err) => hub_err,
57            Err(e) => match e.downcast::<sqlx::Error>() {
58                Ok(db_err) => HubError::Database(db_err),
59                Err(e) => HubError::Upstream(e.to_string()),
60            },
61        }
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    /// N-06: UpstreamHttp Display includes both the status code and the message.
70    /// Without this, downstream log lines and JSON error responses would lose
71    /// the status code that distinguishes a transient 503 from a permanent 4xx.
72    #[test]
73    fn upstream_http_display_includes_status_and_msg() {
74        let err = HubError::UpstreamHttp {
75            status: 503,
76            msg: "service unavailable".to_string(),
77        };
78        let s = err.to_string();
79        assert!(s.contains("503"), "status missing from Display: {s}");
80        assert!(
81            s.contains("service unavailable"),
82            "msg missing from Display: {s}"
83        );
84    }
85
86    /// N-06: UpstreamParse Display includes the parse error string. Callers
87    /// pattern-match on the variant; Display is for logs / HTTP error bodies.
88    #[test]
89    fn upstream_parse_display_includes_message() {
90        let err = HubError::UpstreamParse("unexpected token at line 3".to_string());
91        let s = err.to_string();
92        assert!(
93            s.contains("unexpected token at line 3"),
94            "msg missing from Display: {s}"
95        );
96    }
97
98    /// N-06 contract: when an upstream call site wraps UpstreamHttp in
99    /// `anyhow::Error::new(...)` and that error propagates through `?` to a
100    /// HubError consumer, the `From<anyhow::Error>` impl downcasts and
101    /// recovers the specific variant. This is the load-bearing invariant for
102    /// the migration in `ilink/upstream.rs` and `ilink/login.rs`.
103    #[test]
104    fn from_anyhow_preserves_upstream_http_via_downcast() {
105        let original = HubError::UpstreamHttp {
106            status: 429,
107            msg: "rate limited".to_string(),
108        };
109        let wrapped: anyhow::Error = anyhow::Error::new(original);
110        let recovered: HubError = wrapped.into();
111        match recovered {
112            HubError::UpstreamHttp { status, msg } => {
113                assert_eq!(status, 429);
114                assert_eq!(msg, "rate limited");
115            }
116            other => panic!("expected UpstreamHttp, got {other:?}"),
117        }
118    }
119
120    /// N-06 contract: same as above for the parse variant.
121    #[test]
122    fn from_anyhow_preserves_upstream_parse_via_downcast() {
123        let original = HubError::UpstreamParse("bad json".to_string());
124        let wrapped: anyhow::Error = anyhow::Error::new(original);
125        let recovered: HubError = wrapped.into();
126        match recovered {
127            HubError::UpstreamParse(msg) => assert_eq!(msg, "bad json"),
128            other => panic!("expected UpstreamParse, got {other:?}"),
129        }
130    }
131
132    /// Regression guard: an anyhow::Error that does NOT wrap a HubError or a
133    /// sqlx::Error still falls through to the legacy Upstream(string) variant
134    /// so existing call sites that build plain anyhow::Error values (e.g. via
135    /// `anyhow!(...)` macros elsewhere in the codebase) keep working.
136    #[test]
137    fn from_anyhow_collapses_other_errors_to_upstream_string() {
138        let wrapped: anyhow::Error = anyhow::anyhow!("raw anyhow message");
139        let recovered: HubError = wrapped.into();
140        match recovered {
141            HubError::Upstream(s) => assert_eq!(s, "raw anyhow message"),
142            other => panic!("expected Upstream, got {other:?}"),
143        }
144    }
145
146    /// `upstream_http_err` maps transport-level reqwest failures (DNS, TLS,
147    /// connection reset) — where `e.status()` is None — to `status: 0`.
148    /// Verify the variant accepts 0 without panicking on Display so
149    /// downstream log lines and JSON error bodies always have something
150    /// parseable to render.
151    #[test]
152    fn upstream_http_status_zero_is_legal_for_pre_send_failures() {
153        let err = HubError::UpstreamHttp {
154            status: 0,
155            msg: "connection refused".to_string(),
156        };
157        let s = err.to_string();
158        assert!(s.contains("connection refused"), "msg missing: {s}");
159    }
160}