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
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
//! Error type for `krabka-client-consumer`.
use std::collections::BTreeSet;
use krabka_client_core::ClientError;
use thiserror::Error;
pub(crate) fn is_transport_error(
error: &krabka_client_core::ClientError,
includes_timeout: bool,
) -> bool {
matches!(
error,
ClientError::Connect { .. }
| ClientError::Tls { .. }
| ClientError::Sasl { .. }
| ClientError::Disconnected
| ClientError::Io(_)
) || (includes_timeout && matches!(error, ClientError::Timeout(_)))
}
/// Errors returned by [`Consumer`](crate::Consumer).
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConsumerError {
#[error("client: {0}")]
Client(#[from] krabka_client_core::ClientError),
#[error("protocol: {0}")]
Protocol(#[from] krabka_protocol::ProtocolError),
#[error("rebalance failed: {0}")]
RebalanceFailed(String),
/// A rebalance listener callback failed. Kafka's `poll` throws a
/// `KafkaException` with the cause.
#[error("rebalance listener failed: {0}")]
RebalanceListenerFailed(String),
/// A builder setting is not valid. Kafka's `ConfigException`.
#[error("invalid configuration: {0}")]
InvalidConfig(String),
#[error("startup failed after joining group: {0}")]
StartupAfterJoin(Box<ConsumerError>),
#[error("not subscribed to any topic")]
NotSubscribed,
#[error("illegal state: {0}")]
IllegalState(String),
/// An argument of a call is not valid. Kafka's `IllegalArgumentException`.
#[error("invalid argument: {0}")]
InvalidArgument(String),
/// The broker answered `INVALID_TOPIC_EXCEPTION` for the topic. Kafka's
/// `InvalidTopicException`.
#[error("topic '{0}' is invalid")]
InvalidTopic(String),
/// [`WakeupHandle::wakeup`](crate::WakeupHandle::wakeup) woke the call.
/// Kafka's `WakeupException`.
#[error("the consumer was woken up")]
Wakeup,
/// The call needs a group, and the consumer has no group id. Kafka's
/// `InvalidGroupIdException`.
#[error(
"To use the group management or offset commit APIs, you must provide a valid group.id in the consumer configuration."
)]
InvalidGroupId,
/// A call did not complete before its timeout. Kafka's
/// `TimeoutException`.
///
/// `cause` is the last retriable error when a retry loop ran out of time,
/// as Kafka's `CommitRequestManager.maybeWrapAsTimeoutException` wraps it.
/// It is `None` when the call ran out of time while it waited.
#[error("timeout: {message}")]
Timeout {
message: String,
#[source]
cause: Option<Box<ConsumerError>>,
},
#[error("invalid seek offset {0}: must be non-negative")]
InvalidOffset(i64),
/// The call names a partition that the consumer does not own. Kafka's
/// `SubscriptionState.assignedState` raises `IllegalStateException`.
#[error("no current assignment for partition {topic}-{partition}")]
NoCurrentAssignment { topic: String, partition: i32 },
/// An asynchronous commit failed with a retriable error. A later commit
/// can succeed. Kafka's consumer gives `RetriableCommitFailedException` to
/// the `OffsetCommitCallback`.
#[error("offset commit failed with a retriable exception: {0}")]
RetriableCommitFailed(Box<ConsumerError>),
#[error("commit conflict: rejoined since this poll")]
CommitInvalid,
#[error("coordinator unavailable")]
CoordinatorUnavailable,
#[error(
"log truncation detected on {topic}-{partition}: fetch offset {fetch_offset} is past the leader's log; safe offset {safe_offset}"
)]
LogTruncation {
topic: String,
partition: i32,
fetch_offset: i64,
safe_offset: i64,
},
#[error("broker error_code {0}")]
Server(i16),
/// The coordinator answered `GROUP_AUTHORIZATION_FAILED` (30) for the
/// group. Kafka's consumer raises `GroupAuthorizationException`.
#[error("not authorized to access group: {0}")]
GroupAuthorizationFailed(String),
/// The coordinator answered `TOPIC_AUTHORIZATION_FAILED` (29) for
/// partitions of these topics. Kafka's consumer raises
/// `TopicAuthorizationException`.
#[error(
"not authorized to access topics: [{}]",
.0.iter().map(String::as_str).collect::<Vec<_>>().join(", ")
)]
TopicAuthorizationFailed(BTreeSet<String>),
/// An `OffsetFetch` response carried an error code that Kafka's consumer
/// does not retry.
#[error("unexpected error in offset fetch response: error_code {0}")]
OffsetFetchFailed(i16),
/// The coordinator answered `FENCED_INSTANCE_ID` (82) for this
/// `group.instance.id`, because another consumer joined with the same
/// id. `poll` returns this error once. The next `poll` joins the group
/// again. Kafka's consumer raises `FencedInstanceIdException`.
#[error(
"fenced group.instance.id {0}: another consumer with the same group.instance.id joined the group"
)]
FencedInstanceId(String),
/// A commit found that the consumer is not part of an active group:
/// a fatal coordinator error removed the member and no `poll` joined the
/// group again yet, or the coordinator task stopped. Kafka's consumer raises
/// `CommitFailedException`.
#[error(
"offset commit failed: the consumer is not part of an active group; it is likely that the consumer was kicked out of the group"
)]
CommitFailed,
/// The coordinator answered `ILLEGAL_GENERATION` (22), `UNKNOWN_MEMBER_ID`
/// (25) or `FENCED_INSTANCE_ID` (82) while the group was still
/// `PREPARING_REBALANCE`, or `REBALANCE_IN_PROGRESS` (27). Kafka's
/// `ConsumerCoordinator.OffsetCommitResponseHandler` raises
/// `RebalanceInProgressException` for these, and `commitOffsetsSync` does
/// not retry: the offsets are not committed, and the caller must call
/// `poll()` to finish the rebalance before it commits again.
#[error(
"offset commit cannot be completed since the consumer is undergoing a rebalance for group {0}: call poll() and retry"
)]
RebalanceInProgress(String),
/// A partition has no committed offset and `auto.offset.reset = none`.
/// Kafka never auto-resets under `none`
/// (`SubscriptionState.resetInitializingPositions`); it raises
/// `NoOffsetForPartitionException` from `poll()` and `position()` instead.
#[error(
"undefined offset with no reset policy for partitions: [{}]",
.0.iter().map(|(topic, partition)| format!("{topic}-{partition}")).collect::<Vec<_>>().join(", ")
)]
NoOffsetForPartition(BTreeSet<(String, i32)>),
}
impl ConsumerError {
/// A [`ConsumerError::Timeout`] with no cause.
pub(crate) fn timeout(message: impl Into<String>) -> Self {
Self::Timeout {
message: message.into(),
cause: None,
}
}
/// Whether this is an `OffsetFetch` error that Kafka's consumer does not
/// retry, so the application must see it.
pub(crate) fn is_fatal_offset_fetch_error(&self) -> bool {
matches!(
self,
Self::GroupAuthorizationFailed(_)
| Self::TopicAuthorizationFailed(_)
| Self::OffsetFetchFailed(_)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_messages() {
for (_name, error, expected) in [
(
"not subscribed",
ConsumerError::NotSubscribed,
"not subscribed to any topic",
),
("server", ConsumerError::Server(25), "broker error_code 25"),
(
"invalid config",
ConsumerError::InvalidConfig("group_id required".into()),
"invalid configuration: group_id required",
),
(
"group authorization failed",
ConsumerError::GroupAuthorizationFailed("workers".into()),
"not authorized to access group: workers",
),
(
"topic authorization failed",
ConsumerError::TopicAuthorizationFailed(
maplit::btreeset! {"orders".to_string(), "payments".to_string()},
),
"not authorized to access topics: [orders, payments]",
),
(
"offset fetch failed",
ConsumerError::OffsetFetchFailed(6),
"unexpected error in offset fetch response: error_code 6",
),
(
"fenced instance id",
ConsumerError::FencedInstanceId("instance-a".into()),
"fenced group.instance.id instance-a: another consumer with the same group.instance.id joined the group",
),
(
"commit failed",
ConsumerError::CommitFailed,
"offset commit failed: the consumer is not part of an active group; it is likely that the consumer was kicked out of the group",
),
(
"rebalance in progress",
ConsumerError::RebalanceInProgress("group-a".into()),
"offset commit cannot be completed since the consumer is undergoing a rebalance for group group-a: call poll() and retry",
),
(
"no current assignment",
ConsumerError::NoCurrentAssignment {
topic: "t".into(),
partition: 9,
},
"no current assignment for partition t-9",
),
(
"retriable commit failed",
ConsumerError::RetriableCommitFailed(Box::new(ConsumerError::Server(15))),
"offset commit failed with a retriable exception: broker error_code 15",
),
(
"log truncation",
ConsumerError::LogTruncation {
topic: "t".into(),
partition: 3,
fetch_offset: 100,
safe_offset: 42,
},
"log truncation detected on t-3: fetch offset 100 is past the leader's log; safe offset 42",
),
(
"no offset for partition",
ConsumerError::NoOffsetForPartition(
maplit::btreeset! {("orders".to_string(), 0), ("orders".to_string(), 1)},
),
"undefined offset with no reset policy for partitions: [orders-0, orders-1]",
),
] {
assert2::assert!(error.to_string() == expected);
}
}
}