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