@platformatic/kafka 2.9.0-alpha.1 → 2.10.0-alpha.1

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.
@@ -105,7 +105,7 @@ export declare const AclOperations: {
105
105
  readonly DESCRIBE_TOKENS: 14;
106
106
  readonly TWO_PHASE_COMMIT: 15;
107
107
  };
108
- export declare const allowedAclOperations: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 3 | 5 | 6 | 9 | 11 | 12 | 13 | 14 | 15)[];
108
+ export declare const allowedAclOperations: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 12 | 3 | 5 | 6 | 9 | 11 | 13 | 14 | 15)[];
109
109
  export type AclOperationLabel = keyof typeof AclOperations;
110
110
  export type AclOperationValue = (typeof AclOperations)[AclOperationLabel];
111
111
  /** @deprecated Use AclOperationLabel */
@@ -1,7 +1,7 @@
1
1
  import { createPromisifiedCallback, kCallbackPromise, runConcurrentCallbacks } from "../../apis/callbacks.js";
2
2
  import { ConfigResourceTypes, FetchIsolationLevels, FindCoordinatorKeyTypes, legacyConsumerGroupStates } from "../../apis/enumerations.js";
3
3
  import { adminAclsChannel, adminClientQuotasChannel, adminConfigsChannel, adminConsumerGroupOffsetsChannel, adminGroupsChannel, adminLogDirsChannel, adminOffsetsChannel, adminTopicsChannel, createDiagnosticContext } from "../../diagnostic.js";
4
- import { MultipleErrors, UserError } from "../../errors.js";
4
+ import { findErrorBy, MultipleErrors, UserError } from "../../errors.js";
5
5
  import { Reader } from "../../protocol/reader.js";
6
6
  import { Base, kAfterCreate, kCheckNotClosed, kConnections, kGetApi, kGetBootstrapConnection, kGetConnection, kMetadata, kOptions, kPerformDeduplicated, kPerformWithRetry, kValidateOptions } from "../base/base.js";
7
7
  import { adminListOffsetsOptionsValidator, alterClientQuotasOptionsValidator, alterConfigsOptionsValidator, alterConsumerGroupOffsetsOptionsValidator, createAclsOptionsValidator, createPartitionsOptionsValidator, createTopicsOptionsValidator, deleteAclsOptionsValidator, deleteConsumerGroupOffsetsOptionsValidator, deleteGroupsOptionsValidator, deleteRecordsOptionsValidator, deleteTopicsOptionsValidator, describeAclsOptionsValidator, describeClientQuotasOptionsValidator, describeConfigsOptionsValidator, describeGroupsOptionsValidator, describeLogDirsOptionsValidator, findCoordinatorOptionsValidator, incrementalAlterConfigsOptionsValidator, listConsumerGroupOffsetsOptionsValidator, listGroupsOptionsValidator, listTopicsOptionsValidator, removeMembersFromConsumerGroupOptionsValidator } from "./options.js";
@@ -373,7 +373,7 @@ export class Admin extends Base {
373
373
  }
374
374
  }
375
375
  #handleNotControllerError(error, value, callback) {
376
- if (error && error?.findBy?.('apiCode', 41)) {
376
+ if (findErrorBy(error, 'apiCode', 41)) {
377
377
  this[kMetadata]({ topics: [], forceUpdate: true }, (metadataError, metadata) => {
378
378
  /* c8 ignore next 4 - Hard to test */
379
379
  if (metadataError) {
@@ -708,7 +708,7 @@ export declare const createAclsOptionsSchema: {
708
708
  };
709
709
  operation: {
710
710
  type: string;
711
- enum: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 3 | 5 | 6 | 9 | 11 | 12 | 13 | 14 | 15)[];
711
+ enum: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 12 | 3 | 5 | 6 | 9 | 11 | 13 | 14 | 15)[];
712
712
  };
713
713
  permissionType: {
714
714
  type: string;
@@ -767,7 +767,7 @@ export declare const describeAclsOptionsSchema: {
767
767
  };
768
768
  operation: {
769
769
  type: string;
770
- enum: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 3 | 5 | 6 | 9 | 11 | 12 | 13 | 14 | 15)[];
770
+ enum: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 12 | 3 | 5 | 6 | 9 | 11 | 13 | 14 | 15)[];
771
771
  };
772
772
  permissionType: {
773
773
  type: string;
@@ -826,7 +826,7 @@ export declare const deleteAclsOptionsSchema: {
826
826
  };
827
827
  operation: {
828
828
  type: string;
829
- enum: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 3 | 5 | 6 | 9 | 11 | 12 | 13 | 14 | 15)[];
829
+ enum: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 12 | 3 | 5 | 6 | 9 | 11 | 13 | 14 | 15)[];
830
830
  };
831
831
  permissionType: {
832
832
  type: string;
@@ -2,7 +2,7 @@ import { createPromisifiedCallback, kCallbackPromise, runConcurrentCallbacks } f
2
2
  import * as apis from "../../apis/index.js";
3
3
  import { api as apiVersionsV1 } from "../../apis/metadata/api-versions-v1.js";
4
4
  import { baseApisChannel, baseMetadataChannel, createDiagnosticContext, notifyCreation } from "../../diagnostic.js";
5
- import { MultipleErrors, NetworkError, UnsupportedApiError, UserError } from "../../errors.js";
5
+ import { findErrorBy, MultipleErrors, NetworkError, UnsupportedApiError, UserError } from "../../errors.js";
6
6
  import { TypedEventEmitter } from "../../events.js";
7
7
  import { ConnectionPool } from "../../network/connection-pool.js";
8
8
  import { parseBroker } from "../../network/utils.js";
@@ -235,7 +235,12 @@ export class Base extends TypedEventEmitter {
235
235
  }
236
236
  [kCheckNotClosed](callback) {
237
237
  if (this[kClosed]) {
238
- const error = new NetworkError('Client is closed.', { closed: true, instance: this[kInstance] });
238
+ // A closed client never reopens, so this must not be retried.
239
+ const error = new NetworkError('Client is closed.', {
240
+ canRetry: false,
241
+ closed: true,
242
+ instance: this[kInstance]
243
+ });
239
244
  callback(error, undefined);
240
245
  return true;
241
246
  }
@@ -249,9 +254,8 @@ export class Base extends TypedEventEmitter {
249
254
  this.emitWithDebug('client', 'performWithRetry', operationId, attempt, retries);
250
255
  operation((error, result) => {
251
256
  if (error) {
252
- const genericError = error;
253
257
  // Only retry if all the errors in the chain are retriable
254
- const retriable = !genericError.findBy?.('canRetry', false);
258
+ const retriable = !findErrorBy(error, 'canRetry', false);
255
259
  errors.push(error);
256
260
  if (attempt < retries && retriable && !shouldSkipRetry?.(error)) {
257
261
  function onClose() {
@@ -445,7 +449,7 @@ export class Base extends TypedEventEmitter {
445
449
  }, attempt);
446
450
  }, (error, metadata) => {
447
451
  if (error) {
448
- const unknownTopicError = error.findBy('apiCode', 3);
452
+ const unknownTopicError = findErrorBy(error, 'apiCode', 3);
449
453
  if (unknownTopicError) {
450
454
  const topicIndexMatch = unknownTopicError.path?.match(/\/topics\/(\d+)/);
451
455
  /* c8 ignore next 3 - Hard to test */
@@ -454,7 +458,7 @@ export class Base extends TypedEventEmitter {
454
458
  deduplicateCallback(new UserError(`Unknown topic ${topicName}.`));
455
459
  return;
456
460
  }
457
- const hasStaleMetadata = error.findBy('hasStaleMetadata', true);
461
+ const hasStaleMetadata = findErrorBy(error, 'hasStaleMetadata', true);
458
462
  // Stale metadata, we need to fetch everything again
459
463
  /* c8 ignore next 4 - Hard to test */
460
464
  if (hasStaleMetadata) {
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { createPromisifiedCallback, kCallbackPromise, runConcurrentCallbacks } from "../../apis/callbacks.js";
3
3
  import { FetchIsolationLevels, FindCoordinatorKeyTypes } from "../../apis/enumerations.js";
4
4
  import { consumerCommitsChannel, consumerConsumesChannel, consumerFetchesChannel, consumerGroupChannel, consumerHeartbeatChannel, consumerLagChannel, consumerOffsetsChannel, createDiagnosticContext } from "../../diagnostic.js";
5
- import { NetworkError, protocolErrors, UserError } from "../../errors.js";
5
+ import { findErrorBy, NetworkError, protocolErrors, UserError } from "../../errors.js";
6
6
  import { INT32_SIZE } from "../../protocol/definitions.js";
7
7
  import { Reader } from "../../protocol/reader.js";
8
8
  import { IS_CONTROL } from "../../protocol/records.js";
@@ -609,13 +609,12 @@ export class Consumer extends Base {
609
609
  }
610
610
  if (error) {
611
611
  this.#clearPreferredReadReplicas(options.topics, topicIds);
612
- const genericError = error;
613
- if (genericError.findBy?.('apiId', 'FETCH_SESSION_ID_NOT_FOUND') ||
614
- genericError.findBy?.('apiId', 'INVALID_FETCH_SESSION_EPOCH') ||
615
- genericError.findBy?.('apiId', 'FETCH_SESSION_TOPIC_ID_ERROR')) {
612
+ if (findErrorBy(error, 'apiId', 'FETCH_SESSION_ID_NOT_FOUND') ||
613
+ findErrorBy(error, 'apiId', 'INVALID_FETCH_SESSION_EPOCH') ||
614
+ findErrorBy(error, 'apiId', 'FETCH_SESSION_TOPIC_ID_ERROR')) {
616
615
  this.#fetchSessions.delete(node);
617
616
  }
618
- if (genericError.findBy?.('apiId', 'FENCED_LEADER_EPOCH')) {
617
+ if (findErrorBy(error, 'apiId', 'FENCED_LEADER_EPOCH')) {
619
618
  this.#fetchSessions.delete(node);
620
619
  this.clearMetadata();
621
620
  for (const topic of options.topics) {
@@ -830,7 +829,7 @@ export class Consumer extends Base {
830
829
  if (error) {
831
830
  if (this.#useConsumerGroupProtocol &&
832
831
  staleMemberEpochRetries < this[kOptions].retries &&
833
- error.findBy?.('apiId', 'STALE_MEMBER_EPOCH')) {
832
+ findErrorBy(error, 'apiId', 'STALE_MEMBER_EPOCH')) {
834
833
  this.#consumerGroupHeartbeat(this[kOptions], heartbeatError => {
835
834
  if (heartbeatError) {
836
835
  callback(this.#handleError(heartbeatError));
@@ -1080,7 +1079,35 @@ export class Consumer extends Base {
1080
1079
  callback(err);
1081
1080
  });
1082
1081
  }
1083
- #leaveGroupConsumerProtocol(_, callback) {
1082
+ #leaveGroupConsumerProtocol(force, callback) {
1083
+ // Remove streams that might have been exited in the meanwhile
1084
+ for (const stream of this.#streams) {
1085
+ if (stream.closed || stream.destroyed) {
1086
+ this.#streams.delete(stream);
1087
+ }
1088
+ }
1089
+ // Active streams must be closed before leaving the group, exactly like in the classic protocol.
1090
+ // Their fetch loop keeps running until they are closed and, since Consumer.close marks the client
1091
+ // as closed before leaving, any fetch scheduled in the meanwhile would fail with "Client is closed."
1092
+ // and destroy the stream with an error the user never asked for.
1093
+ if (this.#streams.size) {
1094
+ if (!force) {
1095
+ callback(new UserError('Cannot leave group while consuming messages.'));
1096
+ return;
1097
+ }
1098
+ runConcurrentCallbacks('Closing streams failed.', this.#streams, (stream, concurrentCallback) => {
1099
+ stream.close(concurrentCallback);
1100
+ }, error => {
1101
+ /* c8 ignore next 4 - Hard to test */
1102
+ if (error) {
1103
+ callback(error);
1104
+ return;
1105
+ }
1106
+ // All streams are closed, try the operation again without force
1107
+ this.#leaveGroupConsumerProtocol(false, callback);
1108
+ });
1109
+ return;
1110
+ }
1084
1111
  // Leave by sending a heartbeat with memberEpoch = -1
1085
1112
  this.#cancelHeartbeat();
1086
1113
  this.#performDeduplicateGroupOperaton('leaveGroupConsumerProtocol', (connection, groupCallback) => {
@@ -1304,7 +1331,7 @@ export class Consumer extends Base {
1304
1331
  });
1305
1332
  }, error => {
1306
1333
  if (error) {
1307
- const unknownMemberError = error.findBy?.('unknownMemberId', true);
1334
+ const unknownMemberError = findErrorBy(error, 'unknownMemberId', true);
1308
1335
  // This is to avoid throwing an error if a group join was cancelled.
1309
1336
  if (!unknownMemberError) {
1310
1337
  callback(error);
@@ -1586,7 +1613,7 @@ export class Consumer extends Base {
1586
1613
  }
1587
1614
  }
1588
1615
  #getRejoinError(error) {
1589
- const protocolError = error.findBy?.('needsRejoin', true);
1616
+ const protocolError = findErrorBy(error, 'needsRejoin', true);
1590
1617
  if (!protocolError) {
1591
1618
  return null;
1592
1619
  }
@@ -1672,17 +1699,14 @@ export class Consumer extends Base {
1672
1699
  }
1673
1700
  }
1674
1701
  #handleError(error) {
1675
- const kafkaError = error;
1676
- if (kafkaError) {
1677
- if (kafkaError.findBy('hasStaleMetadata', true)) {
1678
- this.clearMetadata();
1679
- }
1680
- else if (kafkaError.findBy('code', 'PLT_KFK_NETWORK') ||
1681
- kafkaError.findBy('apiId', 'COORDINATOR_LOAD_IN_PROGRESS') ||
1682
- kafkaError.findBy('apiId', 'COORDINATOR_NOT_AVAILABLE') ||
1683
- kafkaError.findBy('apiId', 'NOT_COORDINATOR')) {
1684
- this.#coordinatorId = null;
1685
- }
1702
+ if (findErrorBy(error, 'hasStaleMetadata', true)) {
1703
+ this.clearMetadata();
1704
+ }
1705
+ else if (findErrorBy(error, 'code', 'PLT_KFK_NETWORK') ||
1706
+ findErrorBy(error, 'apiId', 'COORDINATOR_LOAD_IN_PROGRESS') ||
1707
+ findErrorBy(error, 'apiId', 'COORDINATOR_NOT_AVAILABLE') ||
1708
+ findErrorBy(error, 'apiId', 'NOT_COORDINATOR')) {
1709
+ this.#coordinatorId = null;
1686
1710
  }
1687
1711
  return error;
1688
1712
  }
@@ -2,7 +2,7 @@ import { Readable } from 'node:stream';
2
2
  import { createPromisifiedCallback, kCallbackPromise, noopCallback } from "../../apis/callbacks.js";
3
3
  import { ListOffsetTimestamps } from "../../apis/enumerations.js";
4
4
  import { consumerReceivesChannel, createDiagnosticContext, notifyCreation } from "../../diagnostic.js";
5
- import { protocolErrors, UserError } from "../../errors.js";
5
+ import { findErrorBy, protocolErrors, UserError } from "../../errors.js";
6
6
  import { IS_CONTROL } from "../../protocol/records.js";
7
7
  import { runAsyncSeries } from "../../registries/abstract.js";
8
8
  import { kAutocommit, kGetFetchNode, kInstance, kRefreshOffsetsAndFetch } from "../../symbols.js";
@@ -454,7 +454,7 @@ export class MessagesStream extends Readable {
454
454
  });
455
455
  }
456
456
  #handleOffsetOutOfRange(error, topicIds, callback) {
457
- if (!error.findBy?.('apiId', 'OFFSET_OUT_OF_RANGE')) {
457
+ if (!findErrorBy(error, 'apiId', 'OFFSET_OUT_OF_RANGE')) {
458
458
  callback(null, false);
459
459
  return;
460
460
  }
@@ -613,6 +613,7 @@ export class MessagesStream extends Readable {
613
613
  let payloadType = 'headerKey';
614
614
  let deserializedKey;
615
615
  let deserializedValue;
616
+ let deserializationError;
616
617
  try {
617
618
  for (const [headerKey, headerValue] of record.headers) {
618
619
  payloadType = 'headerKey';
@@ -640,16 +641,46 @@ export class MessagesStream extends Readable {
640
641
  timestamp: firstTimestamp + record.timestampDelta,
641
642
  commit: commit
642
643
  };
643
- const shouldDestroy = this.#deserializationErrorHandler
644
- ? this.#deserializationErrorHandler(context) !== DeserializationErrorActions.SKIP
645
- : this.#corruptedMessageHandler(record, topic, partition, firstTimestamp, firstOffset, commit, error);
646
- if (shouldDestroy) {
644
+ const action = this.#deserializationErrorHandler
645
+ ? this.#deserializationErrorHandler(context)
646
+ : this.#corruptedMessageHandler(record, topic, partition, firstTimestamp, firstOffset, commit, error)
647
+ ? DeserializationErrorActions.FAIL
648
+ : DeserializationErrorActions.SKIP;
649
+ if (action === DeserializationErrorActions.CONTINUE) {
650
+ /*
651
+ Deliver the message in a degraded form, so that nothing is lost: whatever was
652
+ already deserialized is kept, while the payload which failed and the ones which
653
+ were never attempted, since deserialization goes headers, key and then value,
654
+ are delivered as the original bytes.
655
+
656
+ The error is reported on the message metadata and published on the diagnostic
657
+ channel, which is where callers should react to it: logging here would be a log
658
+ storm waiting to happen.
659
+ */
660
+ diagnosticContext.error = error;
661
+ consumerReceivesChannel.error.publish(diagnosticContext);
662
+ const headersFailed = payloadType === 'headerKey' || payloadType === 'headerValue';
663
+ if (headersFailed) {
664
+ headers.clear();
665
+ for (const [headerKey, headerValue] of record.headers) {
666
+ headers.set(headerKey, headerValue);
667
+ }
668
+ }
669
+ if (headersFailed || payloadType === 'key') {
670
+ deserializedKey = (record.key ?? undefined);
671
+ }
672
+ deserializedValue = (record.value ?? undefined);
673
+ deserializationError = { error, payloadType };
674
+ }
675
+ else if (action === DeserializationErrorActions.SKIP) {
676
+ continue;
677
+ }
678
+ else {
647
679
  diagnosticContext.error = error;
648
680
  consumerReceivesChannel.error.publish(diagnosticContext);
649
681
  this.destroy(new UserError('Failed to deserialize a message.', { cause: error }));
650
682
  return;
651
683
  }
652
- continue;
653
684
  }
654
685
  this.#metricsConsumedMessages?.inc();
655
686
  const message = {
@@ -661,7 +692,12 @@ export class MessagesStream extends Readable {
661
692
  timestamp: firstTimestamp + record.timestampDelta,
662
693
  offset,
663
694
  commit,
664
- metadata: messageMetadata,
695
+ // Deserializers and hooks can enrich the metadata of the message they processed
696
+ metadata: deserializationError
697
+ ? { ...messageMetadata, ...messageToConsume.metadata, deserializationError }
698
+ : messageToConsume.metadata
699
+ ? { ...messageMetadata, ...messageToConsume.metadata }
700
+ : messageMetadata,
665
701
  toJSON: messageToJSON
666
702
  };
667
703
  diagnosticContext.result = message;
@@ -747,7 +783,7 @@ export class MessagesStream extends Readable {
747
783
  // Only destroy the stream when the broker requires a group rejoin
748
784
  // (ILLEGAL_GENERATION, UNKNOWN_MEMBER_ID, REBALANCE_IN_PROGRESS).
749
785
  // Transient coordinator errors must not tear down the consumption loop.
750
- if (error.findBy?.('needsRejoin', true)) {
786
+ if (findErrorBy(error, 'needsRejoin', true)) {
751
787
  this.destroy(error);
752
788
  }
753
789
  return;
@@ -35,6 +35,7 @@ export interface PreferredReadReplica {
35
35
  export declare const DeserializationErrorActions: {
36
36
  readonly FAIL: "fail";
37
37
  readonly SKIP: "skip";
38
+ readonly CONTINUE: "continue";
38
39
  };
39
40
  export type DeserializationErrorAction = (typeof DeserializationErrorActions)[keyof typeof DeserializationErrorActions];
40
41
  export interface DeserializationErrorContext {
@@ -47,6 +48,10 @@ export interface DeserializationErrorContext {
47
48
  timestamp: bigint;
48
49
  commit: Message['commit'];
49
50
  }
51
+ export interface MessageDeserializationError {
52
+ error: unknown;
53
+ payloadType: BeforeHookPayloadType;
54
+ }
50
55
  export type DeserializationErrorHandler = (context: DeserializationErrorContext) => DeserializationErrorAction;
51
56
  /**
52
57
  * @deprecated Use `DeserializationErrorHandler` instead.
@@ -1,6 +1,7 @@
1
1
  export const DeserializationErrorActions = {
2
2
  FAIL: 'fail',
3
- SKIP: 'skip'
3
+ SKIP: 'skip',
4
+ CONTINUE: 'continue'
4
5
  };
5
6
  export const MessagesStreamModes = {
6
7
  LATEST: 'latest',
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
2
2
  import { createPromisifiedCallback, kCallbackPromise, runConcurrentCallbacks } from "../../apis/callbacks.js";
3
3
  import { FindCoordinatorKeyTypes, ProduceAcks } from "../../apis/enumerations.js";
4
4
  import { createDiagnosticContext, producerInitIdempotentChannel, producerSendsChannel, producerTransactionsChannel } from "../../diagnostic.js";
5
- import { GenericError, UserError } from "../../errors.js";
5
+ import { findErrorBy, UserError } from "../../errors.js";
6
6
  import { runAsyncSeries } from "../../registries/abstract.js";
7
7
  import { kInstance, kTransaction, kTransactionAddOffsets, kTransactionAddPartitions, kTransactionCancel, kTransactionCommitOffset, kTransactionEnd, kTransactionFindCoordinator, kTransactionPrepare } from "../../symbols.js";
8
8
  import { emitExperimentalApiWarning, NumericMap } from "../../utils.js";
@@ -775,11 +775,8 @@ export class Producer extends Base {
775
775
  if (error) {
776
776
  // If the last error was due to stale metadata, we retry the operation with this set of messages
777
777
  // since the partition is already set, it should attempt on the new destination
778
- /* c8 ignore next 3 - Hard to test */
779
- const kafkaError = GenericError.isGenericError(error)
780
- ? error
781
- : null;
782
- const hasStaleMetadata = kafkaError?.findBy('hasStaleMetadata', true);
778
+ /* c8 ignore next - Hard to test */
779
+ const hasStaleMetadata = findErrorBy(error, 'hasStaleMetadata', true);
783
780
  if (hasStaleMetadata && repeatOnStaleMetadata) {
784
781
  this.clearMetadata();
785
782
  this.#performSingleDestinationSend(topics, messages, timeout, acks, autocreateTopics, false, produceOptions, recoverOnProducerStateLoss, callback);
@@ -801,17 +798,16 @@ export class Producer extends Base {
801
798
  callback(error, results);
802
799
  }, 0, [], error => {
803
800
  /* c8 ignore next 3 - Hard to test */
804
- if (!repeatOnStaleMetadata || !GenericError.isGenericError(error)) {
801
+ if (!repeatOnStaleMetadata) {
805
802
  return false;
806
803
  }
807
- return !!error.findBy('hasStaleMetadata', true);
804
+ return !!findErrorBy(error, 'hasStaleMetadata', true);
808
805
  });
809
806
  });
810
807
  }
811
808
  #isProducerStateLost(error) {
812
- const kafkaError = error;
813
- return !!(GenericError.isGenericError(error) &&
814
- (kafkaError.findBy('apiId', 'UNKNOWN_PRODUCER_ID') || kafkaError.findBy('apiId', 'OUT_OF_ORDER_SEQUENCE_NUMBER')));
809
+ return !!(findErrorBy(error, 'apiId', 'UNKNOWN_PRODUCER_ID') ||
810
+ findErrorBy(error, 'apiId', 'OUT_OF_ORDER_SEQUENCE_NUMBER'));
815
811
  }
816
812
  #recoverIdempotentProducer(messages, produceOptions, callback) {
817
813
  this.#producerInfo = undefined;
@@ -864,20 +860,17 @@ export class Producer extends Base {
864
860
  });
865
861
  }
866
862
  #handleFencingError(error) {
867
- if (error.findBy?.('producerFenced', true)) {
863
+ if (findErrorBy(error, 'producerFenced', true)) {
868
864
  this.#producerInfo = undefined;
869
865
  this.#transaction = undefined;
870
866
  }
871
867
  }
872
868
  #handleError(error) {
873
- const kafkaError = error;
874
- if (kafkaError) {
875
- if (kafkaError.findBy('code', 'PLT_KFK_NETWORK') ||
876
- kafkaError.findBy('apiId', 'COORDINATOR_LOAD_IN_PROGRESS') ||
877
- kafkaError.findBy('apiId', 'COORDINATOR_NOT_AVAILABLE') ||
878
- kafkaError.findBy('apiId', 'NOT_COORDINATOR')) {
879
- this.#coordinatorId = undefined;
880
- }
869
+ if (findErrorBy(error, 'code', 'PLT_KFK_NETWORK') ||
870
+ findErrorBy(error, 'apiId', 'COORDINATOR_LOAD_IN_PROGRESS') ||
871
+ findErrorBy(error, 'apiId', 'COORDINATOR_NOT_AVAILABLE') ||
872
+ findErrorBy(error, 'apiId', 'NOT_COORDINATOR')) {
873
+ this.#coordinatorId = undefined;
881
874
  }
882
875
  return error;
883
876
  }
package/dist/errors.d.ts CHANGED
@@ -28,6 +28,7 @@ export declare class MultipleErrors extends AggregateError {
28
28
  findBy<ErrorType extends GenericError | MultipleErrors = MultipleErrors>(property: string, value: unknown): ErrorType | null;
29
29
  toString(): string;
30
30
  }
31
+ export declare function findErrorBy<ErrorType extends GenericError = GenericError>(error: Error | null | undefined, property: string, value: unknown): ErrorType | null;
31
32
  export * from './protocol/errors.ts';
32
33
  export declare class AuthenticationError extends GenericError {
33
34
  static code: ErrorCode;
package/dist/errors.js CHANGED
@@ -98,6 +98,18 @@ export class MultipleErrors extends AggregateError {
98
98
  return `Error ${this.code}: ${this.message} (caused by: ${this.errors.map(error => error.toString()).join(', ')})`;
99
99
  }
100
100
  }
101
+ // findBy only exists on the library's own error classes, but errors also come from user code, Node
102
+ // internals and third parties. This brand checks first, so classification is total: an error that
103
+ // cannot be classified reports no match instead of throwing. Use it for any error whose origin is
104
+ // not statically known.
105
+ export function findErrorBy(error, property, value) {
106
+ if (!error || !GenericError.isGenericError(error)) {
107
+ return null;
108
+ }
109
+ // GenericError and MultipleErrors declare findBy with different generic defaults, so the union is
110
+ // not callable. The brand check above guarantees the call is safe either way.
111
+ return error.findBy(property, value);
112
+ }
101
113
  export * from "./protocol/errors.js";
102
114
  export class AuthenticationError extends GenericError {
103
115
  static code = 'PLT_KFK_AUTHENTICATION';
@@ -92,7 +92,9 @@ export class ConnectionPool extends TypedEventEmitter {
92
92
  }
93
93
  #get(broker, callback) {
94
94
  if (this.#closed) {
95
- callback(new Error('Connection pool is closed.'));
95
+ // A closed pool never reopens, so this must not be retried. It also has to be a library error:
96
+ // clients classify errors via findBy, which only exists on GenericError subclasses.
97
+ callback(new NetworkError('Connection pool is closed.', { canRetry: false, closed: true, broker }));
96
98
  return;
97
99
  }
98
100
  if (broker == null || broker.host == null || broker.port == null) {
@@ -187,9 +187,13 @@ export class Reader {
187
187
  return this.readNullableString(compact, encoding) || '';
188
188
  }
189
189
  readUUID() {
190
- const value = this.peekUUID();
190
+ const hex = this.peekUUID();
191
191
  this.position += UUID_SIZE;
192
- return value.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5');
192
+ // Hyphenated by slicing rather than by a capture group regex. This runs once per topic per
193
+ // Fetch response from v13 up, and the regex form measured about fourteen times the cost, which
194
+ // made the newest Fetch versions measurably slower to decode than the ones identifying topics
195
+ // by name.
196
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
193
197
  }
194
198
  readNullableBytes(compact = true) {
195
199
  let length;
@@ -71,6 +71,7 @@ export interface KafkaRecord {
71
71
  export interface MessageToConsume extends KafkaRecord {
72
72
  topic: string;
73
73
  partition: number;
74
+ metadata?: Record<string, unknown>;
74
75
  }
75
76
  export interface RecordsBatch {
76
77
  firstOffset: bigint;
@@ -1,5 +1,6 @@
1
1
  import { type ErrorObject, type Options, type ValidateFunction } from 'ajv';
2
2
  import { type Type } from 'avsc';
3
+ import { type ConnectionOptions as TLSConnectionOptions } from 'node:tls';
3
4
  import { type Root } from 'protobufjs';
4
5
  import { type Callback } from '../apis/definitions.ts';
5
6
  import { type BeforeDeserializationHook, type BeforeHookPayloadType, type BeforeSerializationHook, type Deserializers, type Serializers } from '../clients/serde.ts';
@@ -7,10 +8,25 @@ import { type CredentialProvider, UserError } from '../index.ts';
7
8
  import { type MessageToConsume, type MessageToProduce } from '../protocol/records.ts';
8
9
  import { AbstractSchemaRegistry } from './abstract.ts';
9
10
  type ConfluentSchemaRegistryMessageToProduce = MessageToProduce<unknown, unknown, unknown, unknown>;
11
+ export type UndiciDispatcher = NonNullable<RequestInit['dispatcher']>;
12
+ export interface UndiciAgentOptions {
13
+ connect: TLSConnectionOptions;
14
+ }
15
+ export type UndiciAgentConstructor = new (options: UndiciAgentOptions) => UndiciDispatcher;
16
+ export declare function createUndiciAgent(options: UndiciAgentOptions): UndiciDispatcher;
10
17
  export interface ConfluentSchemaRegistryMetadata {
11
- schemas?: Record<BeforeHookPayloadType, number>;
18
+ schemas?: Partial<Record<BeforeHookPayloadType, number>>;
19
+ }
20
+ export interface ConfluentSchemaRegistryConsumedMetadata {
21
+ lengths?: {
22
+ key?: number;
23
+ value?: number;
24
+ };
12
25
  }
13
26
  export type ConfluentSchemaRegistryProtobufTypeMapper = (id: number, type: BeforeHookPayloadType, context: ConfluentSchemaRegistryMessageToProduce | MessageToConsume) => string;
27
+ export type ConfluentSchemaRegistryHeaders = Record<string, string>;
28
+ export type ConfluentSchemaRegistryHeadersProvider = () => ConfluentSchemaRegistryHeaders | Promise<ConfluentSchemaRegistryHeaders>;
29
+ export type ConfluentSchemaRegistryRetryDelayGetter = (attempt: number, retries: number, error: Error) => number;
14
30
  export interface ConfluentSchemaRegistryOptions {
15
31
  url: string;
16
32
  auth?: {
@@ -18,10 +34,22 @@ export interface ConfluentSchemaRegistryOptions {
18
34
  password?: string | CredentialProvider;
19
35
  token?: string | CredentialProvider;
20
36
  };
37
+ headers?: ConfluentSchemaRegistryHeaders | ConfluentSchemaRegistryHeadersProvider;
38
+ timeout?: number;
39
+ retries?: number;
40
+ retryDelay?: number | ConfluentSchemaRegistryRetryDelayGetter;
41
+ tls?: TLSConnectionOptions;
42
+ ssl?: TLSConnectionOptions;
21
43
  protobufTypeMapper?: ConfluentSchemaRegistryProtobufTypeMapper;
22
44
  jsonValidateSend?: boolean;
23
45
  jsonAjvOptions?: Options;
24
46
  }
47
+ export declare const defaultConfluentSchemaRegistryOptions: {
48
+ timeout: number;
49
+ retries: number;
50
+ retryDelay: number;
51
+ };
52
+ export declare const retriableSchemaRegistryStatuses: number[];
25
53
  export type SchemaValidationPhase = 'serialization' | 'deserialization';
26
54
  export interface SchemaValidationErrorProperties {
27
55
  schemaId: number;
@@ -49,6 +77,8 @@ export interface Schema {
49
77
  type: 'avro' | 'protobuf' | 'json';
50
78
  schema: Type | Root | ValidateFunction;
51
79
  }
80
+ export declare function trackConsumedSchemaId(message: MessageToConsume | undefined, type: BeforeHookPayloadType, schemaId: number): void;
81
+ export declare function trackConsumedPayloadLength(message: MessageToConsume | undefined, type: 'key' | 'value', data: Buffer): void;
52
82
  export declare function defaultProtobufTypeMapper(_: number, type: BeforeHookPayloadType, context: ConfluentSchemaRegistryMessageToProduce | MessageToConsume): string;
53
83
  export declare class ConfluentSchemaRegistry<Key = Buffer, Value = Buffer, HeaderKey = Buffer, HeaderValue = Buffer> extends AbstractSchemaRegistry<number | undefined, Schema, Key, Value, HeaderKey, HeaderValue> {
54
84
  #private;
@@ -2,19 +2,74 @@ import { Ajv } from 'ajv';
2
2
  import { Ajv2020 } from 'ajv/dist/2020.js';
3
3
  import avro from 'avsc';
4
4
  import { createRequire } from 'node:module';
5
+ import { setTimeout as sleep } from 'node:timers/promises';
5
6
  import { jsonDeserializer, jsonSerializer, stringDeserializer, stringSerializer } from "../clients/serde.js";
6
- import { EMPTY_BUFFER, UnsupportedFormatError, UserError } from "../index.js";
7
+ import { EMPTY_BUFFER, MultipleErrors, NetworkError, TimeoutError, UnsupportedFormatError, UserError } from "../index.js";
7
8
  import { getCredential } from "../protocol/sasl/utils.js";
8
9
  import { AbstractSchemaRegistry } from "./abstract.js";
9
10
  const require = createRequire(import.meta.url);
10
11
  const AjvDraft04 = require('ajv-draft-04');
11
12
  const draft06MetaSchema = require('ajv/dist/refs/json-schema-draft-06.json');
13
+ const undiciGlobalDispatcherSymbol = /^undici\.globalDispatcher\.(\d+)$/;
14
+ export function createUndiciAgent(options) {
15
+ /*
16
+ The undici globals are installed the first time undici is loaded, which creating a Request is
17
+ enough to do and which performs no I/O.
18
+ */
19
+ new Request('http://localhost'); // eslint-disable-line no-new
20
+ /*
21
+ The symbol is versioned and more than one version is registered at the same time, pointing at
22
+ different objects: on Node 26 undici.globalDispatcher.1 is a backwards compatibility wrapper and
23
+ only undici.globalDispatcher.2 is the Agent. The highest version available therefore wins.
24
+ */
25
+ const globals = globalThis;
26
+ let version = -1;
27
+ let Agent;
28
+ for (const symbol of Object.getOwnPropertySymbols(globalThis)) {
29
+ const symbolVersion = symbol.description?.match(undiciGlobalDispatcherSymbol)?.[1];
30
+ if (typeof symbolVersion === 'undefined' || Number(symbolVersion) <= version) {
31
+ continue;
32
+ }
33
+ version = Number(symbolVersion);
34
+ Agent = globals[symbol]?.constructor;
35
+ }
36
+ /* c8 ignore next 6 - Only reachable on a Node.js build which does not bundle undici */
37
+ if (typeof Agent !== 'function') {
38
+ throw new UserError('Cannot access the undici Agent bundled with Node.js, which is required to configure TLS for the Schema Registry.');
39
+ }
40
+ return new Agent(options);
41
+ }
42
+ export const defaultConfluentSchemaRegistryOptions = {
43
+ timeout: 5000,
44
+ retries: 3,
45
+ retryDelay: 1000
46
+ };
47
+ // Statuses which are worth retrying: the registry is overloaded, restarting or behind a proxy which is
48
+ export const retriableSchemaRegistryStatuses = [408, 425, 429, 500, 502, 503, 504];
12
49
  export class SchemaValidationError extends UserError {
13
50
  constructor(message, properties) {
14
51
  super(message, properties);
15
52
  this.name = 'SchemaValidationError';
16
53
  }
17
54
  }
55
+ export function trackConsumedSchemaId(message, type, schemaId) {
56
+ /* c8 ignore next 3 - The message is always provided for keys and values */
57
+ if (!message) {
58
+ return;
59
+ }
60
+ const metadata = (message.metadata ??= {});
61
+ metadata.schemas ??= {};
62
+ metadata.schemas[type] = schemaId;
63
+ }
64
+ export function trackConsumedPayloadLength(message, type, data) {
65
+ /* c8 ignore next 3 - The message is always provided for keys and values */
66
+ if (!message) {
67
+ return;
68
+ }
69
+ const metadata = (message.metadata ??= {});
70
+ metadata.lengths ??= {};
71
+ metadata.lengths[type] = data.length;
72
+ }
18
73
  /* c8 ignore next 8 */
19
74
  export function defaultProtobufTypeMapper(_, type, context) {
20
75
  // Confluent Schema Registry convention
@@ -31,6 +86,12 @@ export class ConfluentSchemaRegistry extends AbstractSchemaRegistry {
31
86
  #jsonAjvDraft04;
32
87
  #pendingFetches;
33
88
  #auth;
89
+ #headers;
90
+ #timeout;
91
+ #retries;
92
+ #retryDelay;
93
+ #tls;
94
+ #tlsDispatcher;
34
95
  constructor(options) {
35
96
  super();
36
97
  this.#url = options.url;
@@ -43,7 +104,15 @@ export class ConfluentSchemaRegistry extends AbstractSchemaRegistry {
43
104
  this.#jsonAjvDraft7.addMetaSchema(draft06MetaSchema);
44
105
  this.#jsonAjvDraft04 = new AjvDraft04(jsonAjvOptions);
45
106
  this.#auth = options.auth;
107
+ this.#headers = options.headers;
108
+ this.#timeout = options.timeout ?? defaultConfluentSchemaRegistryOptions.timeout;
109
+ this.#retries = options.retries ?? defaultConfluentSchemaRegistryOptions.retries;
110
+ this.#retryDelay = options.retryDelay ?? defaultConfluentSchemaRegistryOptions.retryDelay;
111
+ this.#tls = options.tls ?? options.ssl;
46
112
  this.#pendingFetches = new Map();
113
+ if (this.#tls && new URL(this.#url).protocol !== 'https:') {
114
+ throw new UserError('TLS options can only be used with a HTTPS Schema Registry URL.', { url: this.#url });
115
+ }
47
116
  }
48
117
  getSchemaId(message, type) {
49
118
  if (message === null) {
@@ -112,7 +181,7 @@ export class ConfluentSchemaRegistry extends AbstractSchemaRegistry {
112
181
  }
113
182
  getBeforeDeserializationHook() {
114
183
  const registry = this;
115
- return function beforeDeserialization(payload, type, _message, callback) {
184
+ return function beforeDeserialization(payload, type, message, callback) {
116
185
  // Extract the schema ID from the message metadata
117
186
  const schemaId = registry.getSchemaId(payload, type);
118
187
  // When no schema ID is found, nothing to do
@@ -120,6 +189,11 @@ export class ConfluentSchemaRegistry extends AbstractSchemaRegistry {
120
189
  callback(null);
121
190
  return;
122
191
  }
192
+ /*
193
+ Track the schema ID before fetching, so that it is exposed on the message metadata even when
194
+ the schema cannot be fetched or the payload cannot be decoded.
195
+ */
196
+ trackConsumedSchemaId(message, type, schemaId);
123
197
  // The schema is already fetch
124
198
  if (registry.get(schemaId)) {
125
199
  callback(null);
@@ -128,28 +202,86 @@ export class ConfluentSchemaRegistry extends AbstractSchemaRegistry {
128
202
  registry.fetchSchema(schemaId, callback);
129
203
  };
130
204
  }
131
- async #fetchSchema(id) {
132
- const requestInit = {};
205
+ async #requestHeaders() {
206
+ let headers;
207
+ if (this.#headers) {
208
+ headers = { ...(typeof this.#headers === 'function' ? await this.#headers() : this.#headers) };
209
+ }
210
+ // Authentication headers are applied last so that auth options always win over custom headers
133
211
  if (this.#auth) {
212
+ headers ??= {};
134
213
  if (this.#auth.token) {
135
214
  const token = await getCredential('token', this.#auth.token);
136
- requestInit.headers = {
137
- Authorization: `Bearer ${token}`
138
- };
215
+ headers.Authorization = `Bearer ${token}`;
139
216
  }
140
217
  else {
141
218
  const username = await getCredential('username', this.#auth.username);
142
219
  const password = await getCredential('password', this.#auth.password);
143
- requestInit.headers = {
144
- Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`
145
- };
220
+ headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
221
+ }
222
+ }
223
+ return headers;
224
+ }
225
+ async #requestSchema(id) {
226
+ const requestInit = {};
227
+ const headers = await this.#requestHeaders();
228
+ if (headers) {
229
+ requestInit.headers = headers;
230
+ }
231
+ if (this.#tls) {
232
+ // Created once per registry, so that connections are pooled across schema fetches
233
+ this.#tlsDispatcher ??= createUndiciAgent({ connect: this.#tls });
234
+ requestInit.dispatcher = this.#tlsDispatcher;
235
+ }
236
+ if (this.#timeout > 0) {
237
+ requestInit.signal = AbortSignal.timeout(this.#timeout);
238
+ }
239
+ let response;
240
+ try {
241
+ response = await fetch(`${this.#url}/schemas/ids/${id}`, requestInit);
242
+ }
243
+ catch (error) {
244
+ if (error.name === 'TimeoutError') {
245
+ throw new TimeoutError(`Fetching a schema timed out after ${this.#timeout} ms.`, {
246
+ canRetry: true,
247
+ cause: error
248
+ });
146
249
  }
250
+ throw new NetworkError('Failed to fetch a schema.', { cause: error });
147
251
  }
148
- const response = await fetch(`${this.#url}/schemas/ids/${id}`, requestInit);
149
252
  if (!response.ok) {
150
- throw new UserError(`Failed to fetch a schema: [HTTP ${response.status}]`, { response: await response.text() });
253
+ throw new UserError(`Failed to fetch a schema: [HTTP ${response.status}]`, {
254
+ response: await response.text(),
255
+ canRetry: retriableSchemaRegistryStatuses.includes(response.status)
256
+ });
151
257
  }
152
- const responseBody = (await response.json());
258
+ return (await response.json());
259
+ }
260
+ async #requestSchemaWithRetries(id) {
261
+ const errors = [];
262
+ for (let attempt = 0;; attempt++) {
263
+ try {
264
+ return await this.#requestSchema(id);
265
+ }
266
+ catch (error) {
267
+ errors.push(error);
268
+ if (attempt >= this.#retries || error.canRetry !== true) {
269
+ if (errors.length === 1) {
270
+ throw errors[0];
271
+ }
272
+ throw new MultipleErrors(`Failed to fetch a schema after ${errors.length} attempts.`, errors);
273
+ }
274
+ const delay = typeof this.#retryDelay === 'function'
275
+ ? this.#retryDelay(attempt + 1, this.#retries, error)
276
+ : this.#retryDelay;
277
+ if (delay > 0) {
278
+ await sleep(delay);
279
+ }
280
+ }
281
+ }
282
+ }
283
+ async #fetchSchema(id) {
284
+ const responseBody = await this.#requestSchemaWithRetries(id);
153
285
  const { schema } = responseBody;
154
286
  const schemaType = responseBody.schemaType ?? 'AVRO';
155
287
  switch (schemaType) {
@@ -232,17 +364,32 @@ export class ConfluentSchemaRegistry extends AbstractSchemaRegistry {
232
364
  return Buffer.concat([header, encodedMessage]);
233
365
  }
234
366
  #schemaDeserializer(type, fallbackDeserializer, data, headers, message) {
367
+ /*
368
+ Deserializing replaces the original payload, so record its size beforehand: it is the only
369
+ chance callers have to account for the actual ingress size of the message.
370
+ */
371
+ if ((type === 'key' || type === 'value') && Buffer.isBuffer(data)) {
372
+ trackConsumedPayloadLength(message, type, data);
373
+ }
235
374
  /* c8 ignore next 3 - Hard to test */
236
375
  if (typeof data === 'undefined' || data.length === 0) {
237
376
  return EMPTY_BUFFER;
238
377
  }
239
- if (type === 'headerKey' || type === 'headerValue') {
378
+ const isHeader = type === 'headerKey' || type === 'headerValue';
379
+ if (isHeader) {
240
380
  message = headers;
241
381
  }
242
382
  const schemaId = this.getSchemaId(data, type);
243
383
  if (!schemaId) {
244
384
  return fallbackDeserializer(data);
245
385
  }
386
+ /*
387
+ Also track the schema ID here, so that it is exposed on the message metadata even when the
388
+ deserializers are used without the before-deserialization hook of this registry.
389
+ */
390
+ if (!isHeader) {
391
+ trackConsumedSchemaId(message, type, schemaId);
392
+ }
246
393
  const schema = this.#schemas.get(schemaId);
247
394
  if (!schema) {
248
395
  throw new UserError(`Schema with ID ${schemaId} not found.`, { missingSchema: schemaId });
@@ -105,7 +105,7 @@ export declare const AclOperations: {
105
105
  readonly DESCRIBE_TOKENS: 14;
106
106
  readonly TWO_PHASE_COMMIT: 15;
107
107
  };
108
- export declare const allowedAclOperations: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 3 | 5 | 6 | 9 | 11 | 12 | 13 | 14 | 15)[];
108
+ export declare const allowedAclOperations: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 12 | 3 | 5 | 6 | 9 | 11 | 13 | 14 | 15)[];
109
109
  export type AclOperationLabel = keyof typeof AclOperations;
110
110
  export type AclOperationValue = (typeof AclOperations)[AclOperationLabel];
111
111
  /** @deprecated Use AclOperationLabel */
@@ -708,7 +708,7 @@ export declare const createAclsOptionsSchema: {
708
708
  };
709
709
  operation: {
710
710
  type: string;
711
- enum: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 3 | 5 | 6 | 9 | 11 | 12 | 13 | 14 | 15)[];
711
+ enum: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 12 | 3 | 5 | 6 | 9 | 11 | 13 | 14 | 15)[];
712
712
  };
713
713
  permissionType: {
714
714
  type: string;
@@ -767,7 +767,7 @@ export declare const describeAclsOptionsSchema: {
767
767
  };
768
768
  operation: {
769
769
  type: string;
770
- enum: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 3 | 5 | 6 | 9 | 11 | 12 | 13 | 14 | 15)[];
770
+ enum: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 12 | 3 | 5 | 6 | 9 | 11 | 13 | 14 | 15)[];
771
771
  };
772
772
  permissionType: {
773
773
  type: string;
@@ -826,7 +826,7 @@ export declare const deleteAclsOptionsSchema: {
826
826
  };
827
827
  operation: {
828
828
  type: string;
829
- enum: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 3 | 5 | 6 | 9 | 11 | 12 | 13 | 14 | 15)[];
829
+ enum: (0 | 1 | 2 | 4 | 8 | 7 | 10 | 12 | 3 | 5 | 6 | 9 | 11 | 13 | 14 | 15)[];
830
830
  };
831
831
  permissionType: {
832
832
  type: string;
@@ -35,6 +35,7 @@ export interface PreferredReadReplica {
35
35
  export declare const DeserializationErrorActions: {
36
36
  readonly FAIL: "fail";
37
37
  readonly SKIP: "skip";
38
+ readonly CONTINUE: "continue";
38
39
  };
39
40
  export type DeserializationErrorAction = (typeof DeserializationErrorActions)[keyof typeof DeserializationErrorActions];
40
41
  export interface DeserializationErrorContext {
@@ -47,6 +48,10 @@ export interface DeserializationErrorContext {
47
48
  timestamp: bigint;
48
49
  commit: Message['commit'];
49
50
  }
51
+ export interface MessageDeserializationError {
52
+ error: unknown;
53
+ payloadType: BeforeHookPayloadType;
54
+ }
50
55
  export type DeserializationErrorHandler = (context: DeserializationErrorContext) => DeserializationErrorAction;
51
56
  /**
52
57
  * @deprecated Use `DeserializationErrorHandler` instead.
@@ -34,6 +34,7 @@ export declare class MultipleErrors extends AggregateError {
34
34
  findBy<ErrorType extends GenericError | MultipleErrors = MultipleErrors>(property: string, value: unknown): ErrorType | null;
35
35
  toString(): string;
36
36
  }
37
+ export declare function findErrorBy<ErrorType extends GenericError = GenericError>(error: Error | null | undefined, property: string, value: unknown): ErrorType | null;
37
38
  export * from "./protocol/errors";
38
39
  export declare class AuthenticationError extends GenericError {
39
40
  static code: ErrorCode;
@@ -71,6 +71,7 @@ export interface KafkaRecord {
71
71
  export interface MessageToConsume extends KafkaRecord {
72
72
  topic: string;
73
73
  partition: number;
74
+ metadata?: Record<string, unknown>;
74
75
  }
75
76
  export interface RecordsBatch {
76
77
  firstOffset: bigint;
@@ -1,5 +1,6 @@
1
1
  import { type ErrorObject, type Options, type ValidateFunction } from 'ajv';
2
2
  import { type Type } from 'avsc';
3
+ import { type ConnectionOptions as TLSConnectionOptions } from 'node:tls';
3
4
  import { type Root } from 'protobufjs';
4
5
  import { type Callback } from "../apis/definitions";
5
6
  import { type BeforeDeserializationHook, type BeforeHookPayloadType, type BeforeSerializationHook, type Deserializers, type Serializers } from "../clients/serde";
@@ -7,10 +8,25 @@ import { type CredentialProvider, UserError } from "../index";
7
8
  import { type MessageToConsume, type MessageToProduce } from "../protocol/records";
8
9
  import { AbstractSchemaRegistry } from "./abstract";
9
10
  type ConfluentSchemaRegistryMessageToProduce = MessageToProduce<unknown, unknown, unknown, unknown>;
11
+ export type UndiciDispatcher = NonNullable<RequestInit['dispatcher']>;
12
+ export interface UndiciAgentOptions {
13
+ connect: TLSConnectionOptions;
14
+ }
15
+ export type UndiciAgentConstructor = new (options: UndiciAgentOptions) => UndiciDispatcher;
16
+ export declare function createUndiciAgent(options: UndiciAgentOptions): UndiciDispatcher;
10
17
  export interface ConfluentSchemaRegistryMetadata {
11
- schemas?: Record<BeforeHookPayloadType, number>;
18
+ schemas?: Partial<Record<BeforeHookPayloadType, number>>;
19
+ }
20
+ export interface ConfluentSchemaRegistryConsumedMetadata {
21
+ lengths?: {
22
+ key?: number;
23
+ value?: number;
24
+ };
12
25
  }
13
26
  export type ConfluentSchemaRegistryProtobufTypeMapper = (id: number, type: BeforeHookPayloadType, context: ConfluentSchemaRegistryMessageToProduce | MessageToConsume) => string;
27
+ export type ConfluentSchemaRegistryHeaders = Record<string, string>;
28
+ export type ConfluentSchemaRegistryHeadersProvider = () => ConfluentSchemaRegistryHeaders | Promise<ConfluentSchemaRegistryHeaders>;
29
+ export type ConfluentSchemaRegistryRetryDelayGetter = (attempt: number, retries: number, error: Error) => number;
14
30
  export interface ConfluentSchemaRegistryOptions {
15
31
  url: string;
16
32
  auth?: {
@@ -18,10 +34,22 @@ export interface ConfluentSchemaRegistryOptions {
18
34
  password?: string | CredentialProvider;
19
35
  token?: string | CredentialProvider;
20
36
  };
37
+ headers?: ConfluentSchemaRegistryHeaders | ConfluentSchemaRegistryHeadersProvider;
38
+ timeout?: number;
39
+ retries?: number;
40
+ retryDelay?: number | ConfluentSchemaRegistryRetryDelayGetter;
41
+ tls?: TLSConnectionOptions;
42
+ ssl?: TLSConnectionOptions;
21
43
  protobufTypeMapper?: ConfluentSchemaRegistryProtobufTypeMapper;
22
44
  jsonValidateSend?: boolean;
23
45
  jsonAjvOptions?: Options;
24
46
  }
47
+ export declare const defaultConfluentSchemaRegistryOptions: {
48
+ timeout: number;
49
+ retries: number;
50
+ retryDelay: number;
51
+ };
52
+ export declare const retriableSchemaRegistryStatuses: number[];
25
53
  export type SchemaValidationPhase = 'serialization' | 'deserialization';
26
54
  export interface SchemaValidationErrorProperties {
27
55
  schemaId: number;
@@ -49,6 +77,8 @@ export interface Schema {
49
77
  type: 'avro' | 'protobuf' | 'json';
50
78
  schema: Type | Root | ValidateFunction;
51
79
  }
80
+ export declare function trackConsumedSchemaId(message: MessageToConsume | undefined, type: BeforeHookPayloadType, schemaId: number): void;
81
+ export declare function trackConsumedPayloadLength(message: MessageToConsume | undefined, type: 'key' | 'value', data: Buffer): void;
52
82
  export declare function defaultProtobufTypeMapper(_: number, type: BeforeHookPayloadType, context: ConfluentSchemaRegistryMessageToProduce | MessageToConsume): string;
53
83
  export declare class ConfluentSchemaRegistry<Key = Buffer, Value = Buffer, HeaderKey = Buffer, HeaderValue = Buffer> extends AbstractSchemaRegistry<number | undefined, Schema, Key, Value, HeaderKey, HeaderValue> {
54
84
  #private;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export const name = "@platformatic/kafka";
2
- export const version = "2.9.0-alpha.1";
2
+ export const version = "2.10.0-alpha.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/kafka",
3
- "version": "2.9.0-alpha.1",
3
+ "version": "2.10.0-alpha.1",
4
4
  "description": "Modern and performant client for Apache Kafka",
5
5
  "homepage": "https://github.com/platformatic/kafka",
6
6
  "author": "Platformatic Inc. <oss@platformatic.dev> (https://platformatic.dev)",