Skip to main content

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}