@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/README.md +87 -0
- package/dist/{chunk-VGUALBZH.mjs → chunk-3QXTW66R.mjs} +246 -14
- package/dist/chunk-3QXTW66R.mjs.map +1 -0
- package/dist/core.d.mts +22 -3
- package/dist/core.d.ts +22 -3
- package/dist/core.js +245 -13
- package/dist/core.js.map +1 -1
- package/dist/core.mjs +1 -1
- package/dist/{envelope-C66_h8r_.d.mts → envelope-BR8d1m8c.d.mts} +18 -1
- package/dist/{envelope-C66_h8r_.d.ts → envelope-BR8d1m8c.d.ts} +18 -1
- package/dist/index.d.mts +10 -7
- package/dist/index.d.ts +10 -7
- package/dist/index.js +245 -13
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/otel.d.mts +1 -1
- package/dist/otel.d.ts +1 -1
- package/dist/testing.d.mts +1 -1
- package/dist/testing.d.ts +1 -1
- package/package.json +1 -1
- package/dist/chunk-VGUALBZH.mjs.map +0 -1
package/dist/index.js
CHANGED
|
@@ -221,9 +221,43 @@ async function sendToDlq(topic2, rawMessage, deps, meta) {
|
|
|
221
221
|
);
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
|
+
var RETRY_HEADER_ATTEMPT = "x-retry-attempt";
|
|
225
|
+
var RETRY_HEADER_AFTER = "x-retry-after";
|
|
226
|
+
var RETRY_HEADER_MAX_RETRIES = "x-retry-max-retries";
|
|
227
|
+
var RETRY_HEADER_ORIGINAL_TOPIC = "x-retry-original-topic";
|
|
228
|
+
async function sendToRetryTopic(originalTopic, rawMessages, attempt, maxRetries, delayMs, originalHeaders, deps) {
|
|
229
|
+
const retryTopic = `${originalTopic}.retry`;
|
|
230
|
+
const {
|
|
231
|
+
[RETRY_HEADER_ATTEMPT]: _a,
|
|
232
|
+
[RETRY_HEADER_AFTER]: _b,
|
|
233
|
+
[RETRY_HEADER_MAX_RETRIES]: _c,
|
|
234
|
+
[RETRY_HEADER_ORIGINAL_TOPIC]: _d,
|
|
235
|
+
...userHeaders
|
|
236
|
+
} = originalHeaders;
|
|
237
|
+
const headers = {
|
|
238
|
+
...userHeaders,
|
|
239
|
+
[RETRY_HEADER_ATTEMPT]: String(attempt),
|
|
240
|
+
[RETRY_HEADER_AFTER]: String(Date.now() + delayMs),
|
|
241
|
+
[RETRY_HEADER_MAX_RETRIES]: String(maxRetries),
|
|
242
|
+
[RETRY_HEADER_ORIGINAL_TOPIC]: originalTopic
|
|
243
|
+
};
|
|
244
|
+
try {
|
|
245
|
+
for (const raw of rawMessages) {
|
|
246
|
+
await deps.producer.send({ topic: retryTopic, messages: [{ value: raw, headers }] });
|
|
247
|
+
}
|
|
248
|
+
deps.logger.warn(
|
|
249
|
+
`Message queued in retry topic ${retryTopic} (attempt ${attempt}/${maxRetries})`
|
|
250
|
+
);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
deps.logger.error(
|
|
253
|
+
`Failed to send message to retry topic ${retryTopic}:`,
|
|
254
|
+
toError(error).stack
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
224
258
|
async function executeWithRetry(fn, ctx, deps) {
|
|
225
|
-
const { envelope, rawMessages, interceptors, dlq, retry, isBatch } = ctx;
|
|
226
|
-
const maxAttempts = retry ? retry.maxRetries + 1 : 1;
|
|
259
|
+
const { envelope, rawMessages, interceptors, dlq, retry, isBatch, retryTopics } = ctx;
|
|
260
|
+
const maxAttempts = retryTopics ? 1 : retry ? retry.maxRetries + 1 : 1;
|
|
227
261
|
const backoffMs = retry?.backoffMs ?? 1e3;
|
|
228
262
|
const maxBackoffMs = retry?.maxBackoffMs ?? 3e4;
|
|
229
263
|
const envelopes = Array.isArray(envelope) ? envelope : [envelope];
|
|
@@ -282,7 +316,19 @@ async function executeWithRetry(fn, ctx, deps) {
|
|
|
282
316
|
`Error processing ${isBatch ? "batch" : "message"} from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
|
|
283
317
|
err.stack
|
|
284
318
|
);
|
|
285
|
-
if (
|
|
319
|
+
if (retryTopics && retry) {
|
|
320
|
+
const cap = Math.min(backoffMs, maxBackoffMs);
|
|
321
|
+
const delay = Math.floor(Math.random() * cap);
|
|
322
|
+
await sendToRetryTopic(
|
|
323
|
+
topic2,
|
|
324
|
+
rawMessages,
|
|
325
|
+
1,
|
|
326
|
+
retry.maxRetries,
|
|
327
|
+
delay,
|
|
328
|
+
envelopes[0]?.headers ?? {},
|
|
329
|
+
deps
|
|
330
|
+
);
|
|
331
|
+
} else if (isLastAttempt) {
|
|
286
332
|
if (dlq) {
|
|
287
333
|
const dlqMeta = {
|
|
288
334
|
error: err,
|
|
@@ -460,7 +506,12 @@ var KafkaClient = class {
|
|
|
460
506
|
this.logger.log("Producer disconnected");
|
|
461
507
|
}
|
|
462
508
|
async startConsumer(topics, handleMessage, options = {}) {
|
|
463
|
-
|
|
509
|
+
if (options.retryTopics && !options.retry) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
"retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
|
|
464
515
|
const deps = { logger: this.logger, producer: this.producer, instrumentation: this.instrumentation, onMessageLost: this.onMessageLost };
|
|
465
516
|
await consumer.run({
|
|
466
517
|
eachMessage: async ({ topic: topic2, partition, message }) => {
|
|
@@ -494,12 +545,23 @@ var KafkaClient = class {
|
|
|
494
545
|
{ correlationId: envelope.correlationId, traceparent: envelope.traceparent },
|
|
495
546
|
() => handleMessage(envelope)
|
|
496
547
|
),
|
|
497
|
-
{ envelope, rawMessages: [raw], interceptors, dlq, retry },
|
|
548
|
+
{ envelope, rawMessages: [raw], interceptors, dlq, retry, retryTopics: options.retryTopics },
|
|
498
549
|
deps
|
|
499
550
|
);
|
|
500
551
|
}
|
|
501
552
|
});
|
|
502
553
|
this.runningConsumers.set(gid, "eachMessage");
|
|
554
|
+
if (options.retryTopics && retry) {
|
|
555
|
+
await this.startRetryTopicConsumers(
|
|
556
|
+
topicNames,
|
|
557
|
+
gid,
|
|
558
|
+
handleMessage,
|
|
559
|
+
retry,
|
|
560
|
+
dlq,
|
|
561
|
+
interceptors,
|
|
562
|
+
schemaMap
|
|
563
|
+
);
|
|
564
|
+
}
|
|
503
565
|
}
|
|
504
566
|
async startBatchConsumer(topics, handleBatch, options = {}) {
|
|
505
567
|
const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", options);
|
|
@@ -564,15 +626,28 @@ var KafkaClient = class {
|
|
|
564
626
|
this.runningConsumers.set(gid, "eachBatch");
|
|
565
627
|
}
|
|
566
628
|
// ── Consumer lifecycle ───────────────────────────────────────────
|
|
567
|
-
async stopConsumer() {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
629
|
+
async stopConsumer(groupId) {
|
|
630
|
+
if (groupId !== void 0) {
|
|
631
|
+
const consumer = this.consumers.get(groupId);
|
|
632
|
+
if (!consumer) {
|
|
633
|
+
this.logger.warn(`stopConsumer: no active consumer for group "${groupId}"`);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
await consumer.disconnect().catch(() => {
|
|
637
|
+
});
|
|
638
|
+
this.consumers.delete(groupId);
|
|
639
|
+
this.runningConsumers.delete(groupId);
|
|
640
|
+
this.logger.log(`Consumer disconnected: group "${groupId}"`);
|
|
641
|
+
} else {
|
|
642
|
+
const tasks = Array.from(this.consumers.values()).map(
|
|
643
|
+
(c) => c.disconnect().catch(() => {
|
|
644
|
+
})
|
|
645
|
+
);
|
|
646
|
+
await Promise.allSettled(tasks);
|
|
647
|
+
this.consumers.clear();
|
|
648
|
+
this.runningConsumers.clear();
|
|
649
|
+
this.logger.log("All consumers disconnected");
|
|
571
650
|
}
|
|
572
|
-
await Promise.allSettled(tasks);
|
|
573
|
-
this.consumers.clear();
|
|
574
|
-
this.runningConsumers.clear();
|
|
575
|
-
this.logger.log("All consumers disconnected");
|
|
576
651
|
}
|
|
577
652
|
/** Check broker connectivity and return status, clientId, and available topics. */
|
|
578
653
|
async checkStatus() {
|
|
@@ -605,7 +680,164 @@ var KafkaClient = class {
|
|
|
605
680
|
this.runningConsumers.clear();
|
|
606
681
|
this.logger.log("All connections closed");
|
|
607
682
|
}
|
|
683
|
+
// ── Retry topic chain ────────────────────────────────────────────
|
|
684
|
+
/**
|
|
685
|
+
* Auto-start companion consumers on `<topic>.retry` for each original topic.
|
|
686
|
+
* Called by `startConsumer` when `retryTopics: true`.
|
|
687
|
+
*
|
|
688
|
+
* Flow per message:
|
|
689
|
+
* 1. Sleep until `x-retry-after` (scheduled by the main consumer or previous retry hop)
|
|
690
|
+
* 2. Call the original handler
|
|
691
|
+
* 3. On failure: if retries remain → re-send to `<originalTopic>.retry` with incremented attempt
|
|
692
|
+
* if exhausted → DLQ or onMessageLost
|
|
693
|
+
*/
|
|
694
|
+
async startRetryTopicConsumers(originalTopics, originalGroupId, handleMessage, retry, dlq, interceptors, schemaMap) {
|
|
695
|
+
const retryTopicNames = originalTopics.map((t) => `${t}.retry`);
|
|
696
|
+
const retryGroupId = `${originalGroupId}-retry`;
|
|
697
|
+
const backoffMs = retry.backoffMs ?? 1e3;
|
|
698
|
+
const maxBackoffMs = retry.maxBackoffMs ?? 3e4;
|
|
699
|
+
const deps = {
|
|
700
|
+
logger: this.logger,
|
|
701
|
+
producer: this.producer,
|
|
702
|
+
instrumentation: this.instrumentation,
|
|
703
|
+
onMessageLost: this.onMessageLost
|
|
704
|
+
};
|
|
705
|
+
for (const rt of retryTopicNames) {
|
|
706
|
+
await this.ensureTopic(rt);
|
|
707
|
+
}
|
|
708
|
+
const consumer = this.getOrCreateConsumer(retryGroupId, false, true);
|
|
709
|
+
await consumer.connect();
|
|
710
|
+
await subscribeWithRetry(consumer, retryTopicNames, this.logger);
|
|
711
|
+
await consumer.run({
|
|
712
|
+
eachMessage: async ({ topic: retryTopic, partition, message }) => {
|
|
713
|
+
if (!message.value) return;
|
|
714
|
+
const raw = message.value.toString();
|
|
715
|
+
const parsed = parseJsonMessage(raw, retryTopic, this.logger);
|
|
716
|
+
if (parsed === null) return;
|
|
717
|
+
const headers = decodeHeaders(message.headers);
|
|
718
|
+
const originalTopic = headers[RETRY_HEADER_ORIGINAL_TOPIC] ?? retryTopic.replace(/\.retry$/, "");
|
|
719
|
+
const currentAttempt = parseInt(
|
|
720
|
+
headers[RETRY_HEADER_ATTEMPT] ?? "1",
|
|
721
|
+
10
|
|
722
|
+
);
|
|
723
|
+
const maxRetries = parseInt(
|
|
724
|
+
headers[RETRY_HEADER_MAX_RETRIES] ?? String(retry.maxRetries),
|
|
725
|
+
10
|
|
726
|
+
);
|
|
727
|
+
const retryAfter = parseInt(
|
|
728
|
+
headers[RETRY_HEADER_AFTER] ?? "0",
|
|
729
|
+
10
|
|
730
|
+
);
|
|
731
|
+
const remaining = retryAfter - Date.now();
|
|
732
|
+
if (remaining > 0) {
|
|
733
|
+
consumer.pause([{ topic: retryTopic, partitions: [partition] }]);
|
|
734
|
+
await sleep(remaining);
|
|
735
|
+
consumer.resume([{ topic: retryTopic, partitions: [partition] }]);
|
|
736
|
+
}
|
|
737
|
+
const validated = await validateWithSchema(
|
|
738
|
+
parsed,
|
|
739
|
+
raw,
|
|
740
|
+
originalTopic,
|
|
741
|
+
schemaMap,
|
|
742
|
+
interceptors,
|
|
743
|
+
dlq,
|
|
744
|
+
{ ...deps, originalHeaders: headers }
|
|
745
|
+
);
|
|
746
|
+
if (validated === null) return;
|
|
747
|
+
const envelope = extractEnvelope(
|
|
748
|
+
validated,
|
|
749
|
+
headers,
|
|
750
|
+
originalTopic,
|
|
751
|
+
partition,
|
|
752
|
+
message.offset
|
|
753
|
+
);
|
|
754
|
+
try {
|
|
755
|
+
const cleanups = [];
|
|
756
|
+
for (const inst of this.instrumentation) {
|
|
757
|
+
const c = inst.beforeConsume?.(envelope);
|
|
758
|
+
if (typeof c === "function") cleanups.push(c);
|
|
759
|
+
}
|
|
760
|
+
for (const interceptor of interceptors) await interceptor.before?.(envelope);
|
|
761
|
+
await runWithEnvelopeContext(
|
|
762
|
+
{ correlationId: envelope.correlationId, traceparent: envelope.traceparent },
|
|
763
|
+
() => handleMessage(envelope)
|
|
764
|
+
);
|
|
765
|
+
for (const interceptor of interceptors) await interceptor.after?.(envelope);
|
|
766
|
+
for (const cleanup of cleanups) cleanup();
|
|
767
|
+
} catch (error) {
|
|
768
|
+
const err = toError(error);
|
|
769
|
+
const nextAttempt = currentAttempt + 1;
|
|
770
|
+
const exhausted = currentAttempt >= maxRetries;
|
|
771
|
+
for (const inst of this.instrumentation) inst.onConsumeError?.(envelope, err);
|
|
772
|
+
const reportedError = exhausted && maxRetries > 1 ? new KafkaRetryExhaustedError(originalTopic, [envelope.payload], maxRetries, { cause: err }) : err;
|
|
773
|
+
for (const interceptor of interceptors) {
|
|
774
|
+
await interceptor.onError?.(envelope, reportedError);
|
|
775
|
+
}
|
|
776
|
+
this.logger.error(
|
|
777
|
+
`Retry consumer error for ${originalTopic} (attempt ${currentAttempt}/${maxRetries}):`,
|
|
778
|
+
err.stack
|
|
779
|
+
);
|
|
780
|
+
if (!exhausted) {
|
|
781
|
+
const cap = Math.min(backoffMs * 2 ** currentAttempt, maxBackoffMs);
|
|
782
|
+
const delay = Math.floor(Math.random() * cap);
|
|
783
|
+
await sendToRetryTopic(
|
|
784
|
+
originalTopic,
|
|
785
|
+
[raw],
|
|
786
|
+
nextAttempt,
|
|
787
|
+
maxRetries,
|
|
788
|
+
delay,
|
|
789
|
+
headers,
|
|
790
|
+
deps
|
|
791
|
+
);
|
|
792
|
+
} else if (dlq) {
|
|
793
|
+
await sendToDlq(originalTopic, raw, deps, {
|
|
794
|
+
error: err,
|
|
795
|
+
// +1 to account for the main consumer's initial attempt before
|
|
796
|
+
// routing to the retry topic, making this consistent with the
|
|
797
|
+
// in-process retry path where attempt counts all tries.
|
|
798
|
+
attempt: currentAttempt + 1,
|
|
799
|
+
originalHeaders: headers
|
|
800
|
+
});
|
|
801
|
+
} else {
|
|
802
|
+
await deps.onMessageLost?.({
|
|
803
|
+
topic: originalTopic,
|
|
804
|
+
error: err,
|
|
805
|
+
attempt: currentAttempt,
|
|
806
|
+
headers
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
this.runningConsumers.set(retryGroupId, "eachMessage");
|
|
813
|
+
await this.waitForPartitionAssignment(consumer, retryTopicNames);
|
|
814
|
+
this.logger.log(
|
|
815
|
+
`Retry topic consumers started for: ${originalTopics.join(", ")} (group: ${retryGroupId})`
|
|
816
|
+
);
|
|
817
|
+
}
|
|
608
818
|
// ── Private helpers ──────────────────────────────────────────────
|
|
819
|
+
/**
|
|
820
|
+
* Poll `consumer.assignment()` until the consumer has received at least one
|
|
821
|
+
* partition for the given topics, then return. Logs a warning and returns
|
|
822
|
+
* (rather than throwing) on timeout so that a slow broker does not break
|
|
823
|
+
* the caller — in the worst case a message sent immediately after would be
|
|
824
|
+
* missed, which is the same behaviour as before this guard was added.
|
|
825
|
+
*/
|
|
826
|
+
async waitForPartitionAssignment(consumer, topics, timeoutMs = 1e4) {
|
|
827
|
+
const topicSet = new Set(topics);
|
|
828
|
+
const deadline = Date.now() + timeoutMs;
|
|
829
|
+
while (Date.now() < deadline) {
|
|
830
|
+
try {
|
|
831
|
+
const assigned = consumer.assignment();
|
|
832
|
+
if (assigned.some((a) => topicSet.has(a.topic))) return;
|
|
833
|
+
} catch {
|
|
834
|
+
}
|
|
835
|
+
await sleep(200);
|
|
836
|
+
}
|
|
837
|
+
this.logger.warn(
|
|
838
|
+
`Retry consumer did not receive partition assignments for [${topics.join(", ")}] within ${timeoutMs}ms`
|
|
839
|
+
);
|
|
840
|
+
}
|
|
609
841
|
getOrCreateConsumer(groupId, fromBeginning, autoCommit) {
|
|
610
842
|
if (!this.consumers.has(groupId)) {
|
|
611
843
|
this.consumers.set(
|