@drarzter/kafka-client 0.5.2 → 0.5.4

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/core.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as TopicMapConstraint, I as IKafkaClient, C as ClientId, G as GroupId, k as KafkaClientOptions, b as TopicDescriptor, n as SendOptions, B as BatchMessageItem, r as TransactionContext, e as EventEnvelope, a as ConsumerOptions, c as BatchMeta } from './envelope-C66_h8r_.js';
2
- export { d as ConsumerInterceptor, E as EnvelopeHeaderOptions, H as HEADER_CORRELATION_ID, f as HEADER_EVENT_ID, g as HEADER_SCHEMA_VERSION, h as HEADER_TIMESTAMP, i as HEADER_TRACEPARENT, j as InferSchema, K as KafkaInstrumentation, l as KafkaLogger, M as MessageHeaders, m as MessageLostContext, R as RetryOptions, S as SchemaLike, o as SubscribeRetryOptions, p as TTopicMessageMap, q as TopicsFrom, s as buildEnvelopeHeaders, t as decodeHeaders, u as extractEnvelope, v as getEnvelopeContext, w as runWithEnvelopeContext, x as topic } from './envelope-C66_h8r_.js';
1
+ import { T as TopicMapConstraint, I as IKafkaClient, C as ClientId, G as GroupId, k as KafkaClientOptions, b as TopicDescriptor, n as SendOptions, B as BatchMessageItem, r as TransactionContext, e as EventEnvelope, a as ConsumerOptions, c as BatchMeta } from './envelope-BR8d1m8c.js';
2
+ export { d as ConsumerInterceptor, E as EnvelopeHeaderOptions, H as HEADER_CORRELATION_ID, f as HEADER_EVENT_ID, g as HEADER_SCHEMA_VERSION, h as HEADER_TIMESTAMP, i as HEADER_TRACEPARENT, j as InferSchema, K as KafkaInstrumentation, l as KafkaLogger, M as MessageHeaders, m as MessageLostContext, R as RetryOptions, S as SchemaLike, o as SubscribeRetryOptions, p as TTopicMessageMap, q as TopicsFrom, s as buildEnvelopeHeaders, t as decodeHeaders, u as extractEnvelope, v as getEnvelopeContext, w as runWithEnvelopeContext, x as topic } from './envelope-BR8d1m8c.js';
3
3
 
4
4
  /**
5
5
  * Type-safe Kafka client.
@@ -44,7 +44,7 @@ declare class KafkaClient<T extends TopicMapConstraint<T>> implements IKafkaClie
44
44
  /** Subscribe to topics and consume messages in batches. */
45
45
  startBatchConsumer<K extends Array<keyof T>>(topics: K, handleBatch: (envelopes: EventEnvelope<T[K[number]]>[], meta: BatchMeta) => Promise<void>, options?: ConsumerOptions<T>): Promise<void>;
46
46
  startBatchConsumer<D extends TopicDescriptor<string & keyof T, T[string & keyof T]>>(topics: D[], handleBatch: (envelopes: EventEnvelope<D["__type"]>[], meta: BatchMeta) => Promise<void>, options?: ConsumerOptions<T>): Promise<void>;
47
- stopConsumer(): Promise<void>;
47
+ stopConsumer(groupId?: string): Promise<void>;
48
48
  /** Check broker connectivity and return status, clientId, and available topics. */
49
49
  checkStatus(): Promise<{
50
50
  status: 'up';
@@ -54,6 +54,25 @@ declare class KafkaClient<T extends TopicMapConstraint<T>> implements IKafkaClie
54
54
  getClientId(): ClientId;
55
55
  /** Gracefully disconnect producer, all consumers, and admin. */
56
56
  disconnect(): Promise<void>;
57
+ /**
58
+ * Auto-start companion consumers on `<topic>.retry` for each original topic.
59
+ * Called by `startConsumer` when `retryTopics: true`.
60
+ *
61
+ * Flow per message:
62
+ * 1. Sleep until `x-retry-after` (scheduled by the main consumer or previous retry hop)
63
+ * 2. Call the original handler
64
+ * 3. On failure: if retries remain → re-send to `<originalTopic>.retry` with incremented attempt
65
+ * if exhausted → DLQ or onMessageLost
66
+ */
67
+ private startRetryTopicConsumers;
68
+ /**
69
+ * Poll `consumer.assignment()` until the consumer has received at least one
70
+ * partition for the given topics, then return. Logs a warning and returns
71
+ * (rather than throwing) on timeout so that a slow broker does not break
72
+ * the caller — in the worst case a message sent immediately after would be
73
+ * missed, which is the same behaviour as before this guard was added.
74
+ */
75
+ private waitForPartitionAssignment;
57
76
  private getOrCreateConsumer;
58
77
  private resolveTopicName;
59
78
  private ensureTopic;
package/dist/core.js CHANGED
@@ -204,9 +204,43 @@ async function sendToDlq(topic2, rawMessage, deps, meta) {
204
204
  );
205
205
  }
206
206
  }
207
+ var RETRY_HEADER_ATTEMPT = "x-retry-attempt";
208
+ var RETRY_HEADER_AFTER = "x-retry-after";
209
+ var RETRY_HEADER_MAX_RETRIES = "x-retry-max-retries";
210
+ var RETRY_HEADER_ORIGINAL_TOPIC = "x-retry-original-topic";
211
+ async function sendToRetryTopic(originalTopic, rawMessages, attempt, maxRetries, delayMs, originalHeaders, deps) {
212
+ const retryTopic = `${originalTopic}.retry`;
213
+ const {
214
+ [RETRY_HEADER_ATTEMPT]: _a,
215
+ [RETRY_HEADER_AFTER]: _b,
216
+ [RETRY_HEADER_MAX_RETRIES]: _c,
217
+ [RETRY_HEADER_ORIGINAL_TOPIC]: _d,
218
+ ...userHeaders
219
+ } = originalHeaders;
220
+ const headers = {
221
+ ...userHeaders,
222
+ [RETRY_HEADER_ATTEMPT]: String(attempt),
223
+ [RETRY_HEADER_AFTER]: String(Date.now() + delayMs),
224
+ [RETRY_HEADER_MAX_RETRIES]: String(maxRetries),
225
+ [RETRY_HEADER_ORIGINAL_TOPIC]: originalTopic
226
+ };
227
+ try {
228
+ for (const raw of rawMessages) {
229
+ await deps.producer.send({ topic: retryTopic, messages: [{ value: raw, headers }] });
230
+ }
231
+ deps.logger.warn(
232
+ `Message queued in retry topic ${retryTopic} (attempt ${attempt}/${maxRetries})`
233
+ );
234
+ } catch (error) {
235
+ deps.logger.error(
236
+ `Failed to send message to retry topic ${retryTopic}:`,
237
+ toError(error).stack
238
+ );
239
+ }
240
+ }
207
241
  async function executeWithRetry(fn, ctx, deps) {
208
- const { envelope, rawMessages, interceptors, dlq, retry, isBatch } = ctx;
209
- const maxAttempts = retry ? retry.maxRetries + 1 : 1;
242
+ const { envelope, rawMessages, interceptors, dlq, retry, isBatch, retryTopics } = ctx;
243
+ const maxAttempts = retryTopics ? 1 : retry ? retry.maxRetries + 1 : 1;
210
244
  const backoffMs = retry?.backoffMs ?? 1e3;
211
245
  const maxBackoffMs = retry?.maxBackoffMs ?? 3e4;
212
246
  const envelopes = Array.isArray(envelope) ? envelope : [envelope];
@@ -265,7 +299,19 @@ async function executeWithRetry(fn, ctx, deps) {
265
299
  `Error processing ${isBatch ? "batch" : "message"} from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
266
300
  err.stack
267
301
  );
268
- if (isLastAttempt) {
302
+ if (retryTopics && retry) {
303
+ const cap = Math.min(backoffMs, maxBackoffMs);
304
+ const delay = Math.floor(Math.random() * cap);
305
+ await sendToRetryTopic(
306
+ topic2,
307
+ rawMessages,
308
+ 1,
309
+ retry.maxRetries,
310
+ delay,
311
+ envelopes[0]?.headers ?? {},
312
+ deps
313
+ );
314
+ } else if (isLastAttempt) {
269
315
  if (dlq) {
270
316
  const dlqMeta = {
271
317
  error: err,
@@ -443,7 +489,12 @@ var KafkaClient = class {
443
489
  this.logger.log("Producer disconnected");
444
490
  }
445
491
  async startConsumer(topics, handleMessage, options = {}) {
446
- const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
492
+ if (options.retryTopics && !options.retry) {
493
+ throw new Error(
494
+ "retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
495
+ );
496
+ }
497
+ const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
447
498
  const deps = { logger: this.logger, producer: this.producer, instrumentation: this.instrumentation, onMessageLost: this.onMessageLost };
448
499
  await consumer.run({
449
500
  eachMessage: async ({ topic: topic2, partition, message }) => {
@@ -477,12 +528,23 @@ var KafkaClient = class {
477
528
  { correlationId: envelope.correlationId, traceparent: envelope.traceparent },
478
529
  () => handleMessage(envelope)
479
530
  ),
480
- { envelope, rawMessages: [raw], interceptors, dlq, retry },
531
+ { envelope, rawMessages: [raw], interceptors, dlq, retry, retryTopics: options.retryTopics },
481
532
  deps
482
533
  );
483
534
  }
484
535
  });
485
536
  this.runningConsumers.set(gid, "eachMessage");
537
+ if (options.retryTopics && retry) {
538
+ await this.startRetryTopicConsumers(
539
+ topicNames,
540
+ gid,
541
+ handleMessage,
542
+ retry,
543
+ dlq,
544
+ interceptors,
545
+ schemaMap
546
+ );
547
+ }
486
548
  }
487
549
  async startBatchConsumer(topics, handleBatch, options = {}) {
488
550
  const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", options);
@@ -547,15 +609,28 @@ var KafkaClient = class {
547
609
  this.runningConsumers.set(gid, "eachBatch");
548
610
  }
549
611
  // ── Consumer lifecycle ───────────────────────────────────────────
550
- async stopConsumer() {
551
- const tasks = [];
552
- for (const consumer of this.consumers.values()) {
553
- tasks.push(consumer.disconnect());
612
+ async stopConsumer(groupId) {
613
+ if (groupId !== void 0) {
614
+ const consumer = this.consumers.get(groupId);
615
+ if (!consumer) {
616
+ this.logger.warn(`stopConsumer: no active consumer for group "${groupId}"`);
617
+ return;
618
+ }
619
+ await consumer.disconnect().catch(() => {
620
+ });
621
+ this.consumers.delete(groupId);
622
+ this.runningConsumers.delete(groupId);
623
+ this.logger.log(`Consumer disconnected: group "${groupId}"`);
624
+ } else {
625
+ const tasks = Array.from(this.consumers.values()).map(
626
+ (c) => c.disconnect().catch(() => {
627
+ })
628
+ );
629
+ await Promise.allSettled(tasks);
630
+ this.consumers.clear();
631
+ this.runningConsumers.clear();
632
+ this.logger.log("All consumers disconnected");
554
633
  }
555
- await Promise.allSettled(tasks);
556
- this.consumers.clear();
557
- this.runningConsumers.clear();
558
- this.logger.log("All consumers disconnected");
559
634
  }
560
635
  /** Check broker connectivity and return status, clientId, and available topics. */
561
636
  async checkStatus() {
@@ -588,7 +663,164 @@ var KafkaClient = class {
588
663
  this.runningConsumers.clear();
589
664
  this.logger.log("All connections closed");
590
665
  }
666
+ // ── Retry topic chain ────────────────────────────────────────────
667
+ /**
668
+ * Auto-start companion consumers on `<topic>.retry` for each original topic.
669
+ * Called by `startConsumer` when `retryTopics: true`.
670
+ *
671
+ * Flow per message:
672
+ * 1. Sleep until `x-retry-after` (scheduled by the main consumer or previous retry hop)
673
+ * 2. Call the original handler
674
+ * 3. On failure: if retries remain → re-send to `<originalTopic>.retry` with incremented attempt
675
+ * if exhausted → DLQ or onMessageLost
676
+ */
677
+ async startRetryTopicConsumers(originalTopics, originalGroupId, handleMessage, retry, dlq, interceptors, schemaMap) {
678
+ const retryTopicNames = originalTopics.map((t) => `${t}.retry`);
679
+ const retryGroupId = `${originalGroupId}-retry`;
680
+ const backoffMs = retry.backoffMs ?? 1e3;
681
+ const maxBackoffMs = retry.maxBackoffMs ?? 3e4;
682
+ const deps = {
683
+ logger: this.logger,
684
+ producer: this.producer,
685
+ instrumentation: this.instrumentation,
686
+ onMessageLost: this.onMessageLost
687
+ };
688
+ for (const rt of retryTopicNames) {
689
+ await this.ensureTopic(rt);
690
+ }
691
+ const consumer = this.getOrCreateConsumer(retryGroupId, false, true);
692
+ await consumer.connect();
693
+ await subscribeWithRetry(consumer, retryTopicNames, this.logger);
694
+ await consumer.run({
695
+ eachMessage: async ({ topic: retryTopic, partition, message }) => {
696
+ if (!message.value) return;
697
+ const raw = message.value.toString();
698
+ const parsed = parseJsonMessage(raw, retryTopic, this.logger);
699
+ if (parsed === null) return;
700
+ const headers = decodeHeaders(message.headers);
701
+ const originalTopic = headers[RETRY_HEADER_ORIGINAL_TOPIC] ?? retryTopic.replace(/\.retry$/, "");
702
+ const currentAttempt = parseInt(
703
+ headers[RETRY_HEADER_ATTEMPT] ?? "1",
704
+ 10
705
+ );
706
+ const maxRetries = parseInt(
707
+ headers[RETRY_HEADER_MAX_RETRIES] ?? String(retry.maxRetries),
708
+ 10
709
+ );
710
+ const retryAfter = parseInt(
711
+ headers[RETRY_HEADER_AFTER] ?? "0",
712
+ 10
713
+ );
714
+ const remaining = retryAfter - Date.now();
715
+ if (remaining > 0) {
716
+ consumer.pause([{ topic: retryTopic, partitions: [partition] }]);
717
+ await sleep(remaining);
718
+ consumer.resume([{ topic: retryTopic, partitions: [partition] }]);
719
+ }
720
+ const validated = await validateWithSchema(
721
+ parsed,
722
+ raw,
723
+ originalTopic,
724
+ schemaMap,
725
+ interceptors,
726
+ dlq,
727
+ { ...deps, originalHeaders: headers }
728
+ );
729
+ if (validated === null) return;
730
+ const envelope = extractEnvelope(
731
+ validated,
732
+ headers,
733
+ originalTopic,
734
+ partition,
735
+ message.offset
736
+ );
737
+ try {
738
+ const cleanups = [];
739
+ for (const inst of this.instrumentation) {
740
+ const c = inst.beforeConsume?.(envelope);
741
+ if (typeof c === "function") cleanups.push(c);
742
+ }
743
+ for (const interceptor of interceptors) await interceptor.before?.(envelope);
744
+ await runWithEnvelopeContext(
745
+ { correlationId: envelope.correlationId, traceparent: envelope.traceparent },
746
+ () => handleMessage(envelope)
747
+ );
748
+ for (const interceptor of interceptors) await interceptor.after?.(envelope);
749
+ for (const cleanup of cleanups) cleanup();
750
+ } catch (error) {
751
+ const err = toError(error);
752
+ const nextAttempt = currentAttempt + 1;
753
+ const exhausted = currentAttempt >= maxRetries;
754
+ for (const inst of this.instrumentation) inst.onConsumeError?.(envelope, err);
755
+ const reportedError = exhausted && maxRetries > 1 ? new KafkaRetryExhaustedError(originalTopic, [envelope.payload], maxRetries, { cause: err }) : err;
756
+ for (const interceptor of interceptors) {
757
+ await interceptor.onError?.(envelope, reportedError);
758
+ }
759
+ this.logger.error(
760
+ `Retry consumer error for ${originalTopic} (attempt ${currentAttempt}/${maxRetries}):`,
761
+ err.stack
762
+ );
763
+ if (!exhausted) {
764
+ const cap = Math.min(backoffMs * 2 ** currentAttempt, maxBackoffMs);
765
+ const delay = Math.floor(Math.random() * cap);
766
+ await sendToRetryTopic(
767
+ originalTopic,
768
+ [raw],
769
+ nextAttempt,
770
+ maxRetries,
771
+ delay,
772
+ headers,
773
+ deps
774
+ );
775
+ } else if (dlq) {
776
+ await sendToDlq(originalTopic, raw, deps, {
777
+ error: err,
778
+ // +1 to account for the main consumer's initial attempt before
779
+ // routing to the retry topic, making this consistent with the
780
+ // in-process retry path where attempt counts all tries.
781
+ attempt: currentAttempt + 1,
782
+ originalHeaders: headers
783
+ });
784
+ } else {
785
+ await deps.onMessageLost?.({
786
+ topic: originalTopic,
787
+ error: err,
788
+ attempt: currentAttempt,
789
+ headers
790
+ });
791
+ }
792
+ }
793
+ }
794
+ });
795
+ this.runningConsumers.set(retryGroupId, "eachMessage");
796
+ await this.waitForPartitionAssignment(consumer, retryTopicNames);
797
+ this.logger.log(
798
+ `Retry topic consumers started for: ${originalTopics.join(", ")} (group: ${retryGroupId})`
799
+ );
800
+ }
591
801
  // ── Private helpers ──────────────────────────────────────────────
802
+ /**
803
+ * Poll `consumer.assignment()` until the consumer has received at least one
804
+ * partition for the given topics, then return. Logs a warning and returns
805
+ * (rather than throwing) on timeout so that a slow broker does not break
806
+ * the caller — in the worst case a message sent immediately after would be
807
+ * missed, which is the same behaviour as before this guard was added.
808
+ */
809
+ async waitForPartitionAssignment(consumer, topics, timeoutMs = 1e4) {
810
+ const topicSet = new Set(topics);
811
+ const deadline = Date.now() + timeoutMs;
812
+ while (Date.now() < deadline) {
813
+ try {
814
+ const assigned = consumer.assignment();
815
+ if (assigned.some((a) => topicSet.has(a.topic))) return;
816
+ } catch {
817
+ }
818
+ await sleep(200);
819
+ }
820
+ this.logger.warn(
821
+ `Retry consumer did not receive partition assignments for [${topics.join(", ")}] within ${timeoutMs}ms`
822
+ );
823
+ }
592
824
  getOrCreateConsumer(groupId, fromBeginning, autoCommit) {
593
825
  if (!this.consumers.has(groupId)) {
594
826
  this.consumers.set(