Skip to main content

krabka_client_consumer/
error.rs

1//! Error type for `krabka-client-consumer`.
2
3use std::collections::BTreeSet;
4
5use krabka_client_core::ClientError;
6use thiserror::Error;
7
8pub(crate) fn is_transport_error(
9    error: &krabka_client_core::ClientError,
10    includes_timeout: bool,
11) -> bool {
12    matches!(
13        error,
14        ClientError::Connect { .. }
15            | ClientError::Tls { .. }
16            | ClientError::Sasl { .. }
17            | ClientError::Disconnected
18            | ClientError::Io(_)
19    ) || (includes_timeout && matches!(error, ClientError::Timeout(_)))
20}
21
22/// Errors returned by [`Consumer`](crate::Consumer).
23#[derive(Debug, Error)]
24#[non_exhaustive]
25pub enum ConsumerError {
26    #[error("client: {0}")]
27    Client(#[from] krabka_client_core::ClientError),
28
29    #[error("protocol: {0}")]
30    Protocol(#[from] krabka_protocol::ProtocolError),
31
32    #[error("rebalance failed: {0}")]
33    RebalanceFailed(String),
34
35    /// A rebalance listener callback failed. Kafka's `poll` throws a
36    /// `KafkaException` with the cause.
37    #[error("rebalance listener failed: {0}")]
38    RebalanceListenerFailed(String),
39
40    /// A builder setting is not valid. Kafka's `ConfigException`.
41    #[error("invalid configuration: {0}")]
42    InvalidConfig(String),
43
44    #[error("startup failed after joining group: {0}")]
45    StartupAfterJoin(Box<ConsumerError>),
46
47    #[error("not subscribed to any topic")]
48    NotSubscribed,
49
50    #[error("illegal state: {0}")]
51    IllegalState(String),
52
53    /// An argument of a call is not valid. Kafka's `IllegalArgumentException`.
54    #[error("invalid argument: {0}")]
55    InvalidArgument(String),
56
57    /// The broker answered `INVALID_TOPIC_EXCEPTION` for the topic. Kafka's
58    /// `InvalidTopicException`.
59    #[error("topic '{0}' is invalid")]
60    InvalidTopic(String),
61
62    /// [`WakeupHandle::wakeup`](crate::WakeupHandle::wakeup) woke the call.
63    /// Kafka's `WakeupException`.
64    #[error("the consumer was woken up")]
65    Wakeup,
66
67    /// The call needs a group, and the consumer has no group id. Kafka's
68    /// `InvalidGroupIdException`.
69    #[error(
70        "To use the group management or offset commit APIs, you must provide a valid group.id in the consumer configuration."
71    )]
72    InvalidGroupId,
73
74    /// A call did not complete before its timeout. Kafka's
75    /// `TimeoutException`.
76    ///
77    /// `cause` is the last retriable error when a retry loop ran out of time,
78    /// as Kafka's `CommitRequestManager.maybeWrapAsTimeoutException` wraps it.
79    /// It is `None` when the call ran out of time while it waited.
80    #[error("timeout: {message}")]
81    Timeout {
82        message: String,
83        #[source]
84        cause: Option<Box<ConsumerError>>,
85    },
86
87    #[error("invalid seek offset {0}: must be non-negative")]
88    InvalidOffset(i64),
89
90    /// The call names a partition that the consumer does not own. Kafka's
91    /// `SubscriptionState.assignedState` raises `IllegalStateException`.
92    #[error("no current assignment for partition {topic}-{partition}")]
93    NoCurrentAssignment { topic: String, partition: i32 },
94
95    /// An asynchronous commit failed with a retriable error. A later commit
96    /// can succeed. Kafka's consumer gives `RetriableCommitFailedException` to
97    /// the `OffsetCommitCallback`.
98    #[error("offset commit failed with a retriable exception: {0}")]
99    RetriableCommitFailed(Box<ConsumerError>),
100
101    #[error("commit conflict: rejoined since this poll")]
102    CommitInvalid,
103
104    #[error("coordinator unavailable")]
105    CoordinatorUnavailable,
106
107    #[error(
108        "log truncation detected on {topic}-{partition}: fetch offset {fetch_offset} is past the leader's log; safe offset {safe_offset}"
109    )]
110    LogTruncation {
111        topic: String,
112        partition: i32,
113        fetch_offset: i64,
114        safe_offset: i64,
115    },
116
117    #[error("broker error_code {0}")]
118    Server(i16),
119
120    /// The coordinator answered `GROUP_AUTHORIZATION_FAILED` (30) for the
121    /// group. Kafka's consumer raises `GroupAuthorizationException`.
122    #[error("not authorized to access group: {0}")]
123    GroupAuthorizationFailed(String),
124
125    /// The coordinator answered `TOPIC_AUTHORIZATION_FAILED` (29) for
126    /// partitions of these topics. Kafka's consumer raises
127    /// `TopicAuthorizationException`.
128    #[error(
129        "not authorized to access topics: [{}]",
130        .0.iter().map(String::as_str).collect::<Vec<_>>().join(", ")
131    )]
132    TopicAuthorizationFailed(BTreeSet<String>),
133
134    /// An `OffsetFetch` response carried an error code that Kafka's consumer
135    /// does not retry.
136    #[error("unexpected error in offset fetch response: error_code {0}")]
137    OffsetFetchFailed(i16),
138
139    /// The coordinator answered `FENCED_INSTANCE_ID` (82) for this
140    /// `group.instance.id`, because another consumer joined with the same
141    /// id. `poll` returns this error once. The next `poll` joins the group
142    /// again. Kafka's consumer raises `FencedInstanceIdException`.
143    #[error(
144        "fenced group.instance.id {0}: another consumer with the same group.instance.id joined the group"
145    )]
146    FencedInstanceId(String),
147
148    /// A commit found that the consumer is not part of an active group:
149    /// a fatal coordinator error removed the member and no `poll` joined the
150    /// group again yet, or the coordinator task stopped. Kafka's consumer raises
151    /// `CommitFailedException`.
152    #[error(
153        "offset commit failed: the consumer is not part of an active group; it is likely that the consumer was kicked out of the group"
154    )]
155    CommitFailed,
156
157    /// The coordinator answered `ILLEGAL_GENERATION` (22), `UNKNOWN_MEMBER_ID`
158    /// (25) or `FENCED_INSTANCE_ID` (82) while the group was still
159    /// `PREPARING_REBALANCE`, or `REBALANCE_IN_PROGRESS` (27). Kafka's
160    /// `ConsumerCoordinator.OffsetCommitResponseHandler` raises
161    /// `RebalanceInProgressException` for these, and `commitOffsetsSync` does
162    /// not retry: the offsets are not committed, and the caller must call
163    /// `poll()` to finish the rebalance before it commits again.
164    #[error(
165        "offset commit cannot be completed since the consumer is undergoing a rebalance for group {0}: call poll() and retry"
166    )]
167    RebalanceInProgress(String),
168
169    /// A partition has no committed offset and `auto.offset.reset = none`.
170    /// Kafka never auto-resets under `none`
171    /// (`SubscriptionState.resetInitializingPositions`); it raises
172    /// `NoOffsetForPartitionException` from `poll()` and `position()` instead.
173    #[error(
174        "undefined offset with no reset policy for partitions: [{}]",
175        .0.iter().map(|(topic, partition)| format!("{topic}-{partition}")).collect::<Vec<_>>().join(", ")
176    )]
177    NoOffsetForPartition(BTreeSet<(String, i32)>),
178}
179
180impl ConsumerError {
181    /// A [`ConsumerError::Timeout`] with no cause.
182    pub(crate) fn timeout(message: impl Into<String>) -> Self {
183        Self::Timeout {
184            message: message.into(),
185            cause: None,
186        }
187    }
188
189    /// Whether this is an `OffsetFetch` error that Kafka's consumer does not
190    /// retry, so the application must see it.
191    pub(crate) fn is_fatal_offset_fetch_error(&self) -> bool {
192        matches!(
193            self,
194            Self::GroupAuthorizationFailed(_)
195                | Self::TopicAuthorizationFailed(_)
196                | Self::OffsetFetchFailed(_)
197        )
198    }
199}
200
201#[cfg(test)]
202mod tests {
203
204    use super::*;
205
206    #[test]
207    fn display_messages() {
208        for (_name, error, expected) in [
209            (
210                "not subscribed",
211                ConsumerError::NotSubscribed,
212                "not subscribed to any topic",
213            ),
214            ("server", ConsumerError::Server(25), "broker error_code 25"),
215            (
216                "invalid config",
217                ConsumerError::InvalidConfig("group_id required".into()),
218                "invalid configuration: group_id required",
219            ),
220            (
221                "group authorization failed",
222                ConsumerError::GroupAuthorizationFailed("workers".into()),
223                "not authorized to access group: workers",
224            ),
225            (
226                "topic authorization failed",
227                ConsumerError::TopicAuthorizationFailed(
228                    maplit::btreeset! {"orders".to_string(), "payments".to_string()},
229                ),
230                "not authorized to access topics: [orders, payments]",
231            ),
232            (
233                "offset fetch failed",
234                ConsumerError::OffsetFetchFailed(6),
235                "unexpected error in offset fetch response: error_code 6",
236            ),
237            (
238                "fenced instance id",
239                ConsumerError::FencedInstanceId("instance-a".into()),
240                "fenced group.instance.id instance-a: another consumer with the same group.instance.id joined the group",
241            ),
242            (
243                "commit failed",
244                ConsumerError::CommitFailed,
245                "offset commit failed: the consumer is not part of an active group; it is likely that the consumer was kicked out of the group",
246            ),
247            (
248                "rebalance in progress",
249                ConsumerError::RebalanceInProgress("group-a".into()),
250                "offset commit cannot be completed since the consumer is undergoing a rebalance for group group-a: call poll() and retry",
251            ),
252            (
253                "no current assignment",
254                ConsumerError::NoCurrentAssignment {
255                    topic: "t".into(),
256                    partition: 9,
257                },
258                "no current assignment for partition t-9",
259            ),
260            (
261                "retriable commit failed",
262                ConsumerError::RetriableCommitFailed(Box::new(ConsumerError::Server(15))),
263                "offset commit failed with a retriable exception: broker error_code 15",
264            ),
265            (
266                "log truncation",
267                ConsumerError::LogTruncation {
268                    topic: "t".into(),
269                    partition: 3,
270                    fetch_offset: 100,
271                    safe_offset: 42,
272                },
273                "log truncation detected on t-3: fetch offset 100 is past the leader's log; safe offset 42",
274            ),
275            (
276                "no offset for partition",
277                ConsumerError::NoOffsetForPartition(
278                    maplit::btreeset! {("orders".to_string(), 0), ("orders".to_string(), 1)},
279                ),
280                "undefined offset with no reset policy for partitions: [orders-0, orders-1]",
281            ),
282        ] {
283            assert2::assert!(error.to_string() == expected);
284        }
285    }
286}