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