@drarzter/kafka-client 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -51,6 +51,10 @@ var KafkaRetryExhaustedError = class extends KafkaProcessingError {
51
51
  };
52
52
 
53
53
  // src/client/kafka.client.ts
54
+ var ACKS_ALL = -1;
55
+ function toError(error) {
56
+ return error instanceof Error ? error : new Error(String(error));
57
+ }
54
58
  var KafkaClient = class {
55
59
  kafka;
56
60
  producer;
@@ -58,8 +62,11 @@ var KafkaClient = class {
58
62
  admin;
59
63
  logger;
60
64
  autoCreateTopicsEnabled;
65
+ strictSchemasEnabled;
61
66
  ensuredTopics = /* @__PURE__ */ new Set();
62
67
  defaultGroupId;
68
+ schemaRegistry = /* @__PURE__ */ new Map();
69
+ runningConsumers = /* @__PURE__ */ new Map();
63
70
  isAdminConnected = false;
64
71
  clientId;
65
72
  constructor(clientId, groupId, brokers, options) {
@@ -67,6 +74,7 @@ var KafkaClient = class {
67
74
  this.defaultGroupId = groupId;
68
75
  this.logger = new Logger(`KafkaClient:${clientId}`);
69
76
  this.autoCreateTopicsEnabled = options?.autoCreateTopics ?? false;
77
+ this.strictSchemasEnabled = options?.strictSchemas ?? true;
70
78
  this.kafka = new Kafka({
71
79
  clientId: this.clientId,
72
80
  brokers
@@ -79,65 +87,17 @@ var KafkaClient = class {
79
87
  });
80
88
  this.admin = this.kafka.admin();
81
89
  }
82
- getOrCreateConsumer(groupId) {
83
- const gid = groupId || this.defaultGroupId;
84
- if (!this.consumers.has(gid)) {
85
- this.consumers.set(gid, this.kafka.consumer({ groupId: gid }));
86
- }
87
- return this.consumers.get(gid);
88
- }
89
- resolveTopicName(topicOrDescriptor) {
90
- if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
91
- if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
92
- return topicOrDescriptor.__topic;
93
- }
94
- return String(topicOrDescriptor);
95
- }
96
- async ensureTopic(topic2) {
97
- if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
98
- if (!this.isAdminConnected) {
99
- await this.admin.connect();
100
- this.isAdminConnected = true;
101
- }
102
- await this.admin.createTopics({
103
- topics: [{ topic: topic2, numPartitions: 1 }]
104
- });
105
- this.ensuredTopics.add(topic2);
106
- }
107
- validateMessage(topicOrDesc, message) {
108
- if (topicOrDesc?.__schema) {
109
- return topicOrDesc.__schema.parse(message);
110
- }
111
- return message;
112
- }
113
90
  async sendMessage(topicOrDesc, message, options = {}) {
114
- const validated = this.validateMessage(topicOrDesc, message);
115
- const topic2 = this.resolveTopicName(topicOrDesc);
116
- await this.ensureTopic(topic2);
117
- await this.producer.send({
118
- topic: topic2,
119
- messages: [
120
- {
121
- value: JSON.stringify(validated),
122
- key: options.key ?? null,
123
- headers: options.headers
124
- }
125
- ],
126
- acks: -1
127
- });
91
+ const payload = this.buildSendPayload(topicOrDesc, [
92
+ { value: message, key: options.key, headers: options.headers }
93
+ ]);
94
+ await this.ensureTopic(payload.topic);
95
+ await this.producer.send(payload);
128
96
  }
129
97
  async sendBatch(topicOrDesc, messages) {
130
- const topic2 = this.resolveTopicName(topicOrDesc);
131
- await this.ensureTopic(topic2);
132
- await this.producer.send({
133
- topic: topic2,
134
- messages: messages.map((m) => ({
135
- value: JSON.stringify(this.validateMessage(topicOrDesc, m.value)),
136
- key: m.key ?? null,
137
- headers: m.headers
138
- })),
139
- acks: -1
140
- });
98
+ const payload = this.buildSendPayload(topicOrDesc, messages);
99
+ await this.ensureTopic(payload.topic);
100
+ await this.producer.send(payload);
141
101
  }
142
102
  /** Execute multiple sends atomically. Commits on success, aborts on error. */
143
103
  async transaction(fn) {
@@ -145,33 +105,16 @@ var KafkaClient = class {
145
105
  try {
146
106
  const ctx = {
147
107
  send: async (topicOrDesc, message, options = {}) => {
148
- const validated = this.validateMessage(topicOrDesc, message);
149
- const topic2 = this.resolveTopicName(topicOrDesc);
150
- await this.ensureTopic(topic2);
151
- await tx.send({
152
- topic: topic2,
153
- messages: [
154
- {
155
- value: JSON.stringify(validated),
156
- key: options.key ?? null,
157
- headers: options.headers
158
- }
159
- ],
160
- acks: -1
161
- });
108
+ const payload = this.buildSendPayload(topicOrDesc, [
109
+ { value: message, key: options.key, headers: options.headers }
110
+ ]);
111
+ await this.ensureTopic(payload.topic);
112
+ await tx.send(payload);
162
113
  },
163
114
  sendBatch: async (topicOrDesc, messages) => {
164
- const topic2 = this.resolveTopicName(topicOrDesc);
165
- await this.ensureTopic(topic2);
166
- await tx.send({
167
- topic: topic2,
168
- messages: messages.map((m) => ({
169
- value: JSON.stringify(this.validateMessage(topicOrDesc, m.value)),
170
- key: m.key ?? null,
171
- headers: m.headers
172
- })),
173
- acks: -1
174
- });
115
+ const payload = this.buildSendPayload(topicOrDesc, messages);
116
+ await this.ensureTopic(payload.topic);
117
+ await tx.send(payload);
175
118
  }
176
119
  };
177
120
  await fn(ctx);
@@ -181,6 +124,7 @@ var KafkaClient = class {
181
124
  throw error;
182
125
  }
183
126
  }
127
+ // ── Producer lifecycle ───────────────────────────────────────────
184
128
  /** Connect the idempotent producer. Called automatically by `KafkaModule.register()`. */
185
129
  async connectProducer() {
186
130
  await this.producer.connect();
@@ -191,103 +135,38 @@ var KafkaClient = class {
191
135
  this.logger.log("Producer disconnected");
192
136
  }
193
137
  async startConsumer(topics, handleMessage, options = {}) {
194
- const {
195
- groupId: optGroupId,
196
- fromBeginning = false,
197
- autoCommit = true,
198
- retry,
199
- dlq = false,
200
- interceptors = [],
201
- schemas: optionSchemas
202
- } = options;
203
- const consumer = this.getOrCreateConsumer(optGroupId);
204
- const schemaMap = this.buildSchemaMap(topics, optionSchemas);
205
- const topicNames = topics.map(
206
- (t) => this.resolveTopicName(t)
207
- );
208
- await consumer.connect();
209
- for (const t of topicNames) {
210
- await this.ensureTopic(t);
211
- }
212
- await consumer.subscribe({ topics: topicNames, fromBeginning });
213
- this.logger.log(`Consumer subscribed to topics: ${topicNames.join(", ")}`);
138
+ const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
214
139
  await consumer.run({
215
- autoCommit,
140
+ autoCommit: options.autoCommit ?? true,
216
141
  eachMessage: async ({ topic: topic2, message }) => {
217
142
  if (!message.value) {
218
143
  this.logger.warn(`Received empty message from topic ${topic2}`);
219
144
  return;
220
145
  }
221
146
  const raw = message.value.toString();
222
- let parsedMessage;
223
- try {
224
- parsedMessage = JSON.parse(raw);
225
- } catch (error) {
226
- this.logger.error(
227
- `Failed to parse message from topic ${topic2}:`,
228
- error instanceof Error ? error.stack : String(error)
229
- );
230
- return;
231
- }
232
- const schema = schemaMap.get(topic2);
233
- if (schema) {
234
- try {
235
- parsedMessage = schema.parse(parsedMessage);
236
- } catch (error) {
237
- const err = error instanceof Error ? error : new Error(String(error));
238
- const validationError = new KafkaValidationError(
239
- topic2,
240
- parsedMessage,
241
- { cause: err }
242
- );
243
- this.logger.error(
244
- `Schema validation failed for topic ${topic2}:`,
245
- err.message
246
- );
247
- if (dlq) await this.sendToDlq(topic2, raw);
248
- for (const interceptor of interceptors) {
249
- await interceptor.onError?.(
250
- parsedMessage,
251
- topic2,
252
- validationError
253
- );
254
- }
255
- return;
256
- }
257
- }
258
- await this.processMessage(parsedMessage, raw, topic2, handleMessage, {
259
- retry,
260
- dlq,
261
- interceptors
262
- });
147
+ const parsed = this.parseJsonMessage(raw, topic2);
148
+ if (parsed === null) return;
149
+ const validated = await this.validateWithSchema(
150
+ parsed,
151
+ raw,
152
+ topic2,
153
+ schemaMap,
154
+ interceptors,
155
+ dlq
156
+ );
157
+ if (validated === null) return;
158
+ await this.executeWithRetry(
159
+ () => handleMessage(validated, topic2),
160
+ { topic: topic2, messages: validated, rawMessages: [raw], interceptors, dlq, retry }
161
+ );
263
162
  }
264
163
  });
164
+ this.runningConsumers.set(gid, "eachMessage");
265
165
  }
266
166
  async startBatchConsumer(topics, handleBatch, options = {}) {
267
- const {
268
- groupId: optGroupId,
269
- fromBeginning = false,
270
- autoCommit = true,
271
- retry,
272
- dlq = false,
273
- interceptors = [],
274
- schemas: optionSchemas
275
- } = options;
276
- const consumer = this.getOrCreateConsumer(optGroupId);
277
- const schemaMap = this.buildSchemaMap(topics, optionSchemas);
278
- const topicNames = topics.map(
279
- (t) => this.resolveTopicName(t)
280
- );
281
- await consumer.connect();
282
- for (const t of topicNames) {
283
- await this.ensureTopic(t);
284
- }
285
- await consumer.subscribe({ topics: topicNames, fromBeginning });
286
- this.logger.log(
287
- `Batch consumer subscribed to topics: ${topicNames.join(", ")}`
288
- );
167
+ const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", options);
289
168
  await consumer.run({
290
- autoCommit,
169
+ autoCommit: options.autoCommit ?? true,
291
170
  eachBatch: async ({
292
171
  batch,
293
172
  heartbeat,
@@ -295,6 +174,7 @@ var KafkaClient = class {
295
174
  commitOffsetsIfNecessary
296
175
  }) => {
297
176
  const validMessages = [];
177
+ const rawMessages = [];
298
178
  for (const message of batch.messages) {
299
179
  if (!message.value) {
300
180
  this.logger.warn(
@@ -303,43 +183,19 @@ var KafkaClient = class {
303
183
  continue;
304
184
  }
305
185
  const raw = message.value.toString();
306
- let parsedMessage;
307
- try {
308
- parsedMessage = JSON.parse(raw);
309
- } catch (error) {
310
- this.logger.error(
311
- `Failed to parse message from topic ${batch.topic}:`,
312
- error instanceof Error ? error.stack : String(error)
313
- );
314
- continue;
315
- }
316
- const schema = schemaMap.get(batch.topic);
317
- if (schema) {
318
- try {
319
- parsedMessage = schema.parse(parsedMessage);
320
- } catch (error) {
321
- const err = error instanceof Error ? error : new Error(String(error));
322
- const validationError = new KafkaValidationError(
323
- batch.topic,
324
- parsedMessage,
325
- { cause: err }
326
- );
327
- this.logger.error(
328
- `Schema validation failed for topic ${batch.topic}:`,
329
- err.message
330
- );
331
- if (dlq) await this.sendToDlq(batch.topic, raw);
332
- for (const interceptor of interceptors) {
333
- await interceptor.onError?.(
334
- parsedMessage,
335
- batch.topic,
336
- validationError
337
- );
338
- }
339
- continue;
340
- }
341
- }
342
- validMessages.push(parsedMessage);
186
+ const parsed = this.parseJsonMessage(raw, batch.topic);
187
+ if (parsed === null) continue;
188
+ const validated = await this.validateWithSchema(
189
+ parsed,
190
+ raw,
191
+ batch.topic,
192
+ schemaMap,
193
+ interceptors,
194
+ dlq
195
+ );
196
+ if (validated === null) continue;
197
+ validMessages.push(validated);
198
+ rawMessages.push(raw);
343
199
  }
344
200
  if (validMessages.length === 0) return;
345
201
  const meta = {
@@ -349,68 +205,23 @@ var KafkaClient = class {
349
205
  resolveOffset,
350
206
  commitOffsetsIfNecessary
351
207
  };
352
- const maxAttempts = retry ? retry.maxRetries + 1 : 1;
353
- const backoffMs = retry?.backoffMs ?? 1e3;
354
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
355
- try {
356
- for (const interceptor of interceptors) {
357
- for (const msg of validMessages) {
358
- await interceptor.before?.(msg, batch.topic);
359
- }
360
- }
361
- await handleBatch(validMessages, batch.topic, meta);
362
- for (const interceptor of interceptors) {
363
- for (const msg of validMessages) {
364
- await interceptor.after?.(msg, batch.topic);
365
- }
366
- }
367
- return;
368
- } catch (error) {
369
- const err = error instanceof Error ? error : new Error(String(error));
370
- const isLastAttempt = attempt === maxAttempts;
371
- if (isLastAttempt && maxAttempts > 1) {
372
- const exhaustedError = new KafkaRetryExhaustedError(
373
- batch.topic,
374
- validMessages,
375
- maxAttempts,
376
- { cause: err }
377
- );
378
- for (const interceptor of interceptors) {
379
- await interceptor.onError?.(
380
- validMessages,
381
- batch.topic,
382
- exhaustedError
383
- );
384
- }
385
- } else {
386
- for (const interceptor of interceptors) {
387
- await interceptor.onError?.(
388
- validMessages,
389
- batch.topic,
390
- err
391
- );
392
- }
393
- }
394
- this.logger.error(
395
- `Error processing batch from topic ${batch.topic} (attempt ${attempt}/${maxAttempts}):`,
396
- err.stack
397
- );
398
- if (isLastAttempt) {
399
- if (dlq) {
400
- for (const msg of batch.messages) {
401
- if (msg.value) {
402
- await this.sendToDlq(batch.topic, msg.value.toString());
403
- }
404
- }
405
- }
406
- } else {
407
- await this.sleep(backoffMs * attempt);
408
- }
208
+ await this.executeWithRetry(
209
+ () => handleBatch(validMessages, batch.topic, meta),
210
+ {
211
+ topic: batch.topic,
212
+ messages: validMessages,
213
+ rawMessages: batch.messages.filter((m) => m.value).map((m) => m.value.toString()),
214
+ interceptors,
215
+ dlq,
216
+ retry,
217
+ isBatch: true
409
218
  }
410
- }
219
+ );
411
220
  }
412
221
  });
222
+ this.runningConsumers.set(gid, "eachBatch");
413
223
  }
224
+ // ── Consumer lifecycle ───────────────────────────────────────────
414
225
  async stopConsumer() {
415
226
  const tasks = [];
416
227
  for (const consumer of this.consumers.values()) {
@@ -418,6 +229,7 @@ var KafkaClient = class {
418
229
  }
419
230
  await Promise.allSettled(tasks);
420
231
  this.consumers.clear();
232
+ this.runningConsumers.clear();
421
233
  this.logger.log("All consumers disconnected");
422
234
  }
423
235
  /** Check broker connectivity and return available topics. */
@@ -444,61 +256,217 @@ var KafkaClient = class {
444
256
  }
445
257
  await Promise.allSettled(tasks);
446
258
  this.consumers.clear();
259
+ this.runningConsumers.clear();
447
260
  this.logger.log("All connections closed");
448
261
  }
449
- // --- Private helpers ---
262
+ // ── Private helpers ──────────────────────────────────────────────
263
+ getOrCreateConsumer(groupId) {
264
+ const gid = groupId || this.defaultGroupId;
265
+ if (!this.consumers.has(gid)) {
266
+ this.consumers.set(gid, this.kafka.consumer({ groupId: gid }));
267
+ }
268
+ return this.consumers.get(gid);
269
+ }
270
+ resolveTopicName(topicOrDescriptor) {
271
+ if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
272
+ if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
273
+ return topicOrDescriptor.__topic;
274
+ }
275
+ return String(topicOrDescriptor);
276
+ }
277
+ async ensureTopic(topic2) {
278
+ if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
279
+ if (!this.isAdminConnected) {
280
+ await this.admin.connect();
281
+ this.isAdminConnected = true;
282
+ }
283
+ await this.admin.createTopics({
284
+ topics: [{ topic: topic2, numPartitions: 1 }]
285
+ });
286
+ this.ensuredTopics.add(topic2);
287
+ }
288
+ /** Register schema from descriptor into global registry (side-effect). */
289
+ registerSchema(topicOrDesc) {
290
+ if (topicOrDesc?.__schema) {
291
+ const topic2 = this.resolveTopicName(topicOrDesc);
292
+ this.schemaRegistry.set(topic2, topicOrDesc.__schema);
293
+ }
294
+ }
295
+ /** Validate message against schema. Pure — no side-effects on registry. */
296
+ validateMessage(topicOrDesc, message) {
297
+ if (topicOrDesc?.__schema) {
298
+ return topicOrDesc.__schema.parse(message);
299
+ }
300
+ if (this.strictSchemasEnabled && typeof topicOrDesc === "string") {
301
+ const schema = this.schemaRegistry.get(topicOrDesc);
302
+ if (schema) return schema.parse(message);
303
+ }
304
+ return message;
305
+ }
306
+ /**
307
+ * Build a kafkajs-ready send payload.
308
+ * Handles: topic resolution, schema registration, validation, JSON serialization.
309
+ */
310
+ buildSendPayload(topicOrDesc, messages) {
311
+ this.registerSchema(topicOrDesc);
312
+ const topic2 = this.resolveTopicName(topicOrDesc);
313
+ return {
314
+ topic: topic2,
315
+ messages: messages.map((m) => ({
316
+ value: JSON.stringify(this.validateMessage(topicOrDesc, m.value)),
317
+ key: m.key ?? null,
318
+ headers: m.headers
319
+ })),
320
+ acks: ACKS_ALL
321
+ };
322
+ }
323
+ /** Shared consumer setup: groupId check, schema map, connect, subscribe. */
324
+ async setupConsumer(topics, mode, options) {
325
+ const {
326
+ groupId: optGroupId,
327
+ fromBeginning = false,
328
+ retry,
329
+ dlq = false,
330
+ interceptors = [],
331
+ schemas: optionSchemas
332
+ } = options;
333
+ const gid = optGroupId || this.defaultGroupId;
334
+ const existingMode = this.runningConsumers.get(gid);
335
+ const oppositeMode = mode === "eachMessage" ? "eachBatch" : "eachMessage";
336
+ if (existingMode === oppositeMode) {
337
+ throw new Error(
338
+ `Cannot use ${mode} on consumer group "${gid}" \u2014 it is already running with ${oppositeMode}. Use a different groupId for this consumer.`
339
+ );
340
+ }
341
+ const consumer = this.getOrCreateConsumer(optGroupId);
342
+ const schemaMap = this.buildSchemaMap(topics, optionSchemas);
343
+ const topicNames = topics.map(
344
+ (t) => this.resolveTopicName(t)
345
+ );
346
+ await consumer.connect();
347
+ await this.subscribeWithRetry(consumer, topicNames, fromBeginning, options.subscribeRetry);
348
+ this.logger.log(
349
+ `${mode === "eachBatch" ? "Batch consumer" : "Consumer"} subscribed to topics: ${topicNames.join(", ")}`
350
+ );
351
+ return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry };
352
+ }
450
353
  buildSchemaMap(topics, optionSchemas) {
451
354
  const schemaMap = /* @__PURE__ */ new Map();
452
355
  for (const t of topics) {
453
356
  if (t?.__schema) {
454
- schemaMap.set(this.resolveTopicName(t), t.__schema);
357
+ const name = this.resolveTopicName(t);
358
+ schemaMap.set(name, t.__schema);
359
+ this.schemaRegistry.set(name, t.__schema);
455
360
  }
456
361
  }
457
362
  if (optionSchemas) {
458
363
  for (const [k, v] of optionSchemas) {
459
364
  schemaMap.set(k, v);
365
+ this.schemaRegistry.set(k, v);
460
366
  }
461
367
  }
462
368
  return schemaMap;
463
369
  }
464
- async processMessage(parsedMessage, raw, topic2, handleMessage, opts) {
465
- const { retry, dlq = false, interceptors = [] } = opts;
370
+ /** Parse raw message as JSON. Returns null on failure (logs error). */
371
+ parseJsonMessage(raw, topic2) {
372
+ try {
373
+ return JSON.parse(raw);
374
+ } catch (error) {
375
+ this.logger.error(
376
+ `Failed to parse message from topic ${topic2}:`,
377
+ toError(error).stack
378
+ );
379
+ return null;
380
+ }
381
+ }
382
+ /**
383
+ * Validate a parsed message against the schema map.
384
+ * On failure: logs error, sends to DLQ if enabled, calls interceptor.onError.
385
+ * Returns validated message or null.
386
+ */
387
+ async validateWithSchema(message, raw, topic2, schemaMap, interceptors, dlq) {
388
+ const schema = schemaMap.get(topic2);
389
+ if (!schema) return message;
390
+ try {
391
+ return schema.parse(message);
392
+ } catch (error) {
393
+ const err = toError(error);
394
+ const validationError = new KafkaValidationError(topic2, message, {
395
+ cause: err
396
+ });
397
+ this.logger.error(
398
+ `Schema validation failed for topic ${topic2}:`,
399
+ err.message
400
+ );
401
+ if (dlq) await this.sendToDlq(topic2, raw);
402
+ for (const interceptor of interceptors) {
403
+ await interceptor.onError?.(message, topic2, validationError);
404
+ }
405
+ return null;
406
+ }
407
+ }
408
+ /**
409
+ * Execute a handler with retry, interceptors, and DLQ support.
410
+ * Used by both single-message and batch consumers.
411
+ */
412
+ async executeWithRetry(fn, ctx) {
413
+ const { topic: topic2, messages, rawMessages, interceptors, dlq, retry, isBatch } = ctx;
466
414
  const maxAttempts = retry ? retry.maxRetries + 1 : 1;
467
415
  const backoffMs = retry?.backoffMs ?? 1e3;
468
416
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
469
417
  try {
470
- for (const interceptor of interceptors) {
471
- await interceptor.before?.(parsedMessage, topic2);
418
+ if (isBatch) {
419
+ for (const interceptor of interceptors) {
420
+ for (const msg of messages) {
421
+ await interceptor.before?.(msg, topic2);
422
+ }
423
+ }
424
+ } else {
425
+ for (const interceptor of interceptors) {
426
+ await interceptor.before?.(messages, topic2);
427
+ }
472
428
  }
473
- await handleMessage(parsedMessage, topic2);
474
- for (const interceptor of interceptors) {
475
- await interceptor.after?.(parsedMessage, topic2);
429
+ await fn();
430
+ if (isBatch) {
431
+ for (const interceptor of interceptors) {
432
+ for (const msg of messages) {
433
+ await interceptor.after?.(msg, topic2);
434
+ }
435
+ }
436
+ } else {
437
+ for (const interceptor of interceptors) {
438
+ await interceptor.after?.(messages, topic2);
439
+ }
476
440
  }
477
441
  return;
478
442
  } catch (error) {
479
- const err = error instanceof Error ? error : new Error(String(error));
443
+ const err = toError(error);
480
444
  const isLastAttempt = attempt === maxAttempts;
481
445
  if (isLastAttempt && maxAttempts > 1) {
482
446
  const exhaustedError = new KafkaRetryExhaustedError(
483
447
  topic2,
484
- parsedMessage,
448
+ messages,
485
449
  maxAttempts,
486
450
  { cause: err }
487
451
  );
488
452
  for (const interceptor of interceptors) {
489
- await interceptor.onError?.(parsedMessage, topic2, exhaustedError);
453
+ await interceptor.onError?.(messages, topic2, exhaustedError);
490
454
  }
491
455
  } else {
492
456
  for (const interceptor of interceptors) {
493
- await interceptor.onError?.(parsedMessage, topic2, err);
457
+ await interceptor.onError?.(messages, topic2, err);
494
458
  }
495
459
  }
496
460
  this.logger.error(
497
- `Error processing message from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
461
+ `Error processing ${isBatch ? "batch" : "message"} from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
498
462
  err.stack
499
463
  );
500
464
  if (isLastAttempt) {
501
- if (dlq) await this.sendToDlq(topic2, raw);
465
+ if (dlq) {
466
+ for (const raw of rawMessages) {
467
+ await this.sendToDlq(topic2, raw);
468
+ }
469
+ }
502
470
  } else {
503
471
  await this.sleep(backoffMs * attempt);
504
472
  }
@@ -511,16 +479,33 @@ var KafkaClient = class {
511
479
  await this.producer.send({
512
480
  topic: dlqTopic,
513
481
  messages: [{ value: rawMessage }],
514
- acks: -1
482
+ acks: ACKS_ALL
515
483
  });
516
484
  this.logger.warn(`Message sent to DLQ: ${dlqTopic}`);
517
485
  } catch (error) {
518
486
  this.logger.error(
519
487
  `Failed to send message to DLQ ${dlqTopic}:`,
520
- error instanceof Error ? error.stack : String(error)
488
+ toError(error).stack
521
489
  );
522
490
  }
523
491
  }
492
+ async subscribeWithRetry(consumer, topics, fromBeginning, retryOpts) {
493
+ const maxAttempts = retryOpts?.retries ?? 5;
494
+ const backoffMs = retryOpts?.backoffMs ?? 5e3;
495
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
496
+ try {
497
+ await consumer.subscribe({ topics, fromBeginning });
498
+ return;
499
+ } catch (error) {
500
+ if (attempt === maxAttempts) throw error;
501
+ const msg = toError(error).message;
502
+ this.logger.warn(
503
+ `Failed to subscribe to [${topics.join(", ")}] (attempt ${attempt}/${maxAttempts}): ${msg}. Retrying in ${backoffMs}ms...`
504
+ );
505
+ await this.sleep(backoffMs);
506
+ }
507
+ }
508
+ }
524
509
  sleep(ms) {
525
510
  return new Promise((resolve) => setTimeout(resolve, ms));
526
511
  }