kafka_conn/error.rs
1//! The client error type.
2//!
3//! M5 asks for the failure modes to be distinguishable *at the type level*,
4//! because a UI renders them differently: a transport error is "the cluster is
5//! unreachable", an authorization error is "ask your admin", a decode failure
6//! is "this is our bug". Collapsing them into one string throws that away.
7//!
8//! The broker's own codes live next door in [`crate::error_code`]; this type is
9//! about what happened, that one is about what the broker said.
10
11use std::error::Error as StdError;
12use std::time::Duration;
13
14use crate::api_key::ApiKey;
15use crate::error_code::ErrorCode;
16
17/// Result alias for this workspace.
18pub type Result<T> = std::result::Result<T, Error>;
19
20/// Anything that can go wrong talking to a broker.
21#[derive(Debug, thiserror::Error)]
22#[non_exhaustive]
23pub enum Error {
24 /// The socket failed, or never opened.
25 #[error("{context}: {source}")]
26 Transport {
27 /// What we were doing.
28 context: &'static str,
29 /// The underlying I/O error.
30 #[source]
31 source: std::io::Error,
32 },
33
34 /// The connection is gone. Every in-flight request resolves to this rather
35 /// than hanging — a UI backend that leaks a hung future per dead broker
36 /// stops working long before anyone notices why.
37 #[error("connection to {peer} closed")]
38 ConnectionClosed {
39 /// Which broker, for the log line that follows.
40 peer: String,
41 },
42
43 /// The caller's deadline passed.
44 #[error("{api_key} timed out after {elapsed:?}")]
45 Timeout {
46 /// The request that ran out of time.
47 api_key: ApiKey,
48 /// How long it had.
49 elapsed: Duration,
50 },
51
52 /// The credentials were rejected, or the handshake could not agree.
53 #[error("authentication failed: {0}")]
54 Authentication(String),
55
56 /// The principal authenticated but is not permitted to do this.
57 #[error("not authorized: {0}")]
58 Authorization(ErrorCode),
59
60 /// The broker answered with an error code.
61 #[error("broker returned {code}{}", .message.as_ref().map(|m| format!(": {m}")).unwrap_or_default())]
62 Broker {
63 /// The classified code.
64 code: ErrorCode,
65 /// The broker's own message, when the response carries one.
66 message: Option<String>,
67 },
68
69 /// A response did not parse.
70 ///
71 /// Distinct from every other variant because it means *we* are wrong: a
72 /// version negotiated badly, or a schema drifted.
73 #[error("{context}: {source}")]
74 Decode {
75 /// What we were decoding.
76 context: &'static str,
77 /// The underlying decode failure.
78 #[source]
79 source: Box<dyn StdError + Send + Sync>,
80 },
81
82 /// A read-only client refused a mutating request before touching the
83 /// network. See [`ApiKey::is_mutating`].
84 #[error("client is read-only and {api_key} mutates cluster state")]
85 ReadOnly {
86 /// The key that was refused.
87 api_key: ApiKey,
88 },
89
90 /// No version of this API is speakable by both ends.
91 ///
92 /// Usually *our* side is the binding one: `kafka-protocol` 0.17 ships
93 /// Kafka 4.0 schemas and the broker is newer.
94 #[error("no usable version of {api_key}: broker offers {broker:?}, we speak {ours:?}")]
95 UnsupportedApi {
96 /// The key.
97 api_key: ApiKey,
98 /// The broker's `(min, max)`, if it advertised the key at all.
99 broker: Option<(i16, i16)>,
100 /// Our `(min, max)`, if this build knows the key.
101 ours: Option<(i16, i16)>,
102 },
103
104 /// The caller asked for something the protocol or this build cannot
105 /// express — an unnamed API key, a sentinel that needs a schema version we
106 /// cannot encode. An honest blocker, not a workaround.
107 #[error("unsupported: {0}")]
108 Unsupported(String),
109
110 /// A request was malformed before it went out.
111 #[error("invalid request: {0}")]
112 InvalidRequest(String),
113}
114
115impl Error {
116 /// Wrap an I/O error with context.
117 pub fn transport(context: &'static str, source: std::io::Error) -> Self {
118 Error::Transport { context, source }
119 }
120
121 /// Wrap a decode failure with context.
122 pub fn decode(
123 context: &'static str,
124 source: impl Into<Box<dyn StdError + Send + Sync>>,
125 ) -> Self {
126 Error::Decode {
127 context,
128 source: source.into(),
129 }
130 }
131
132 /// Build the right variant for a broker error code.
133 ///
134 /// Authentication and authorization codes are lifted out of
135 /// [`Error::Broker`] here rather than at every call site, because a caller
136 /// that forgets renders "not authorized" as a generic failure.
137 pub fn from_code(code: ErrorCode, message: Option<String>) -> Self {
138 if code.is_authentication() {
139 Error::Authentication(message.unwrap_or_else(|| code.to_string()))
140 } else if code.is_authorization() {
141 Error::Authorization(code)
142 } else {
143 Error::Broker { code, message }
144 }
145 }
146
147 /// The broker code, when there is one.
148 pub fn code(&self) -> Option<ErrorCode> {
149 match self {
150 Error::Broker { code, .. } | Error::Authorization(code) => Some(*code),
151 _ => None,
152 }
153 }
154
155 /// Whether retrying could plausibly succeed.
156 ///
157 /// A dead connection counts: the pool will open a new one. A decode failure
158 /// does not — retrying a schema mismatch just burns the same bytes again.
159 pub fn retriable(&self) -> bool {
160 match self {
161 Error::Transport { .. } | Error::ConnectionClosed { .. } | Error::Timeout { .. } => {
162 true
163 }
164 Error::Broker { code, .. } => code.retriable(),
165 Error::Authorization(_)
166 | Error::Authentication(_)
167 | Error::Decode { .. }
168 | Error::ReadOnly { .. }
169 | Error::UnsupportedApi { .. }
170 | Error::Unsupported(_)
171 | Error::InvalidRequest(_) => false,
172 }
173 }
174
175 /// Whether handling this error should refresh the metadata snapshot.
176 pub fn needs_metadata_refresh(&self) -> bool {
177 // A dead or unreachable broker is itself evidence the snapshot is
178 // stale, not just evidence that one request failed.
179 match self {
180 Error::Transport { .. } | Error::ConnectionClosed { .. } => true,
181 Error::Broker { code, .. } => code.needs_metadata_refresh(),
182 _ => false,
183 }
184 }
185
186 /// Whether handling this error should invalidate a cached coordinator.
187 pub fn needs_coordinator_refresh(&self) -> bool {
188 match self {
189 Error::Broker { code, .. } => code.needs_coordinator_refresh(),
190 _ => false,
191 }
192 }
193}
194
195/// One failure often has to be reported to many callers: every record in a
196/// rejected produce batch, every partition in a request whose connection died.
197/// Without `Clone` each of those sites has to invent a way to fan an error out,
198/// and they invent different ones.
199///
200/// Two variants cannot be duplicated faithfully and are **reconstructed**:
201///
202/// * [`Error::Transport`] keeps its [`std::io::ErrorKind`] and its rendering,
203/// but a cloned `io::Error` loses the raw OS error code.
204/// * [`Error::Decode`] keeps its source's rendering rather than its concrete
205/// type, so downcasting the clone will not find the original.
206///
207/// Everything [`Error::retriable`], [`Error::code`],
208/// [`Error::needs_metadata_refresh`] and `Display` read is preserved exactly,
209/// which is the whole of what callers branch on. Derived rather than hand-
210/// written is not an option — `io::Error` and a boxed source are not `Clone`.
211impl Clone for Error {
212 fn clone(&self) -> Self {
213 match self {
214 Error::Transport { context, source } => Error::Transport {
215 context,
216 source: std::io::Error::new(source.kind(), source.to_string()),
217 },
218 Error::ConnectionClosed { peer } => Error::ConnectionClosed { peer: peer.clone() },
219 Error::Timeout { api_key, elapsed } => Error::Timeout {
220 api_key: *api_key,
221 elapsed: *elapsed,
222 },
223 Error::Authentication(message) => Error::Authentication(message.clone()),
224 Error::Authorization(code) => Error::Authorization(*code),
225 Error::Broker { code, message } => Error::Broker {
226 code: *code,
227 message: message.clone(),
228 },
229 Error::Decode { context, source } => Error::Decode {
230 context,
231 source: source.to_string().into(),
232 },
233 Error::ReadOnly { api_key } => Error::ReadOnly { api_key: *api_key },
234 Error::UnsupportedApi {
235 api_key,
236 broker,
237 ours,
238 } => Error::UnsupportedApi {
239 api_key: *api_key,
240 broker: *broker,
241 ours: *ours,
242 },
243 Error::Unsupported(message) => Error::Unsupported(message.clone()),
244 Error::InvalidRequest(message) => Error::InvalidRequest(message.clone()),
245 }
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn auth_codes_do_not_hide_inside_broker() {
255 // 58 = SASL_AUTHENTICATION_FAILED, 29 = TOPIC_AUTHORIZATION_FAILED.
256 let authn = Error::from_code(ErrorCode::SaslAuthenticationFailed, None);
257 assert!(matches!(authn, Error::Authentication(_)));
258 let authz = Error::from_code(ErrorCode::TopicAuthorizationFailed, None);
259 assert!(matches!(authz, Error::Authorization(_)));
260 assert!(!authz.retriable());
261 }
262
263 #[test]
264 fn decode_failures_are_never_retried() {
265 let err = Error::decode("test", std::io::Error::other("boom"));
266 assert!(!err.retriable());
267 assert!(!err.needs_metadata_refresh());
268 }
269
270 #[test]
271 fn transport_failures_invalidate_metadata() {
272 let err = Error::transport("connect", std::io::Error::other("boom"));
273 assert!(err.retriable());
274 assert!(err.needs_metadata_refresh());
275 }
276
277 #[test]
278 fn broker_errors_delegate_both_axes() {
279 let err = Error::from_code(ErrorCode::NotLeaderOrFollower, None);
280 assert!(err.retriable());
281 assert!(err.needs_metadata_refresh());
282 assert!(!err.needs_coordinator_refresh());
283
284 let err = Error::from_code(ErrorCode::NotCoordinator, None);
285 assert!(err.needs_coordinator_refresh());
286 assert!(!err.needs_metadata_refresh());
287 }
288}