@zdavison/matador 4.0.1 → 4.0.3

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.
Files changed (38) hide show
  1. package/dist/core/fanout.d.ts +62 -0
  2. package/dist/core/fanout.d.ts.map +1 -1
  3. package/dist/core/fanout.js +217 -15
  4. package/dist/core/fanout.test.js +904 -6
  5. package/dist/core/matador.d.ts +33 -0
  6. package/dist/core/matador.d.ts.map +1 -1
  7. package/dist/core/matador.js +6 -1
  8. package/dist/core/matador.test.js +243 -7
  9. package/dist/errors/index.d.ts +1 -1
  10. package/dist/errors/index.d.ts.map +1 -1
  11. package/dist/errors/index.js +2 -2
  12. package/dist/errors/matador-errors.d.ts +10 -8
  13. package/dist/errors/matador-errors.d.ts.map +1 -1
  14. package/dist/errors/matador-errors.js +18 -14
  15. package/dist/index.cjs +345 -73
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/transport/local/local-transport.d.ts +6 -0
  23. package/dist/transport/local/local-transport.d.ts.map +1 -1
  24. package/dist/transport/local/local-transport.js +35 -18
  25. package/dist/transport/local/local-transport.test.js +40 -4
  26. package/dist/transport/multi/multi-transport.d.ts +6 -0
  27. package/dist/transport/multi/multi-transport.d.ts.map +1 -1
  28. package/dist/transport/multi/multi-transport.js +20 -0
  29. package/dist/transport/multi/multi-transport.test.js +82 -0
  30. package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.js +45 -6
  31. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts +6 -0
  32. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts.map +1 -1
  33. package/dist/transport/rabbitmq/rabbitmq-transport.js +83 -40
  34. package/dist/transport/transport.d.ts +12 -0
  35. package/dist/transport/transport.d.ts.map +1 -1
  36. package/dist/types/event.d.ts +17 -0
  37. package/dist/types/event.d.ts.map +1 -1
  38. package/package.json +1 -1
@@ -12,6 +12,35 @@ export interface FanoutConfig {
12
12
  readonly hooks: SafeHooks;
13
13
  readonly topology: Topology;
14
14
  readonly defaultQueue: string;
15
+ readonly maxRetryBufferSize?: number | undefined;
16
+ /**
17
+ * Maximum number of retry attempts for a buffered message before it's
18
+ * dropped and reported via onEnqueueError, instead of being retried forever.
19
+ *
20
+ * @default undefined (no limit — retries until the buffer flushes successfully)
21
+ */
22
+ readonly maxRetryAttempts?: number | undefined;
23
+ /**
24
+ * How often (in ms) to attempt flushing the retry buffer, independent of
25
+ * transport reconnect events. This covers the case where the transport
26
+ * stays connected but an individual publish keeps failing (e.g. broker
27
+ * nack, publish confirm timeout) — onConnected never fires again for
28
+ * that, so nothing would otherwise retry it.
29
+ *
30
+ * @default 30000. Pass 0 to disable the interval and rely on
31
+ * onConnected/manual flushes only.
32
+ */
33
+ readonly retryIntervalMs?: number | undefined;
34
+ /**
35
+ * Number of consecutive flush failures tolerated within a single flush
36
+ * pass before bailing out early and re-buffering the untried remainder,
37
+ * instead of retrying every buffered message against a broker that's
38
+ * rejecting everything.
39
+ *
40
+ * @default 10. Pass 0 (or a negative number) to disable the breaker and
41
+ * always attempt every buffered message on every flush pass.
42
+ */
43
+ readonly maxConsecutiveFlushFailures?: number | undefined;
15
44
  }
16
45
  /**
17
46
  * Result of sending an event.
@@ -46,7 +75,15 @@ export declare class FanoutEngine {
46
75
  private readonly topology;
47
76
  private readonly defaultQueue;
48
77
  private enqueuingCount;
78
+ private flushInFlightCount;
79
+ private readonly retryBuffer;
80
+ private readonly maxRetryBufferSize;
81
+ private readonly maxRetryAttempts;
82
+ private readonly maxConsecutiveFlushFailures;
83
+ private readonly disposeOnConnected;
84
+ private readonly retryTimer;
49
85
  constructor(config: FanoutConfig);
86
+ dispose(): void;
50
87
  /**
51
88
  * Current count of events being enqueued.
52
89
  */
@@ -55,6 +92,31 @@ export declare class FanoutEngine {
55
92
  * Sends an event to all registered subscribers.
56
93
  */
57
94
  send<T>(eventClass: EventClass<T>, event: Event<T>, options?: EventOptions): Promise<SendResult>;
95
+ /**
96
+ * Attempts a single buffered item during a flush pass
97
+ * @returns true if the attempt succeeded; false on failure
98
+ */
99
+ private attemptFlushItem;
100
+ /**
101
+ * Flushes the retry buffer
102
+ *
103
+ * This is called when the transport reconnects, and is used to retry any messages that were buffered while the transport was disconnected
104
+ */
105
+ private flushRetryBuffer;
106
+ /**
107
+ * Re-buffers messages that were never attempted because a flush pass
108
+ * stopped out early. Respects maxRetryBufferSize: concurrent sends can have
109
+ * refilled the buffer while this flush was running, so anything beyond
110
+ * remaining capacity is dropped and reported instead of silently growing
111
+ * the buffer past its cap.
112
+ */
113
+ private rebufferUntried;
114
+ /**
115
+ * Handles a failed flush attempt for a single buffered item: drops it once
116
+ * maxRetryAttempts is exceeded, otherwise re-buffers it (unless the buffer
117
+ * is full, in which case it's dropped too).
118
+ */
119
+ private handleFlushFailure;
58
120
  private isSubscriberEnabled;
59
121
  }
60
122
  //# sourceMappingURL=fanout.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"fanout.d.ts","sourceRoot":"","sources":["../../src/core/fanout.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAErD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,KAAK,EAEV,KAAK,EACL,UAAU,EACV,YAAY,EACb,MAAM,mBAAmB,CAAC;AAG3B;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,CAAC;CACvC;AAED;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAY;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAW;IACpC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,cAAc,CAAK;gBAEf,MAAM,EAAE,YAAY;IAQhC;;OAEG;IACH,IAAI,wBAAwB,IAAI,MAAM,CAErC;IAED;;OAEG;IACG,IAAI,CAAC,CAAC,EACV,UAAU,EAAE,UAAU,CAAC,CAAC,CAAC,EACzB,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EACf,OAAO,GAAE,YAAiB,GACzB,OAAO,CAAC,UAAU,CAAC;YAsFR,mBAAmB;CAelC"}
1
+ {"version":3,"file":"fanout.d.ts","sourceRoot":"","sources":["../../src/core/fanout.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAErD,OAAO,KAAK,EAAe,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACpE,OAAO,KAAK,EAGV,KAAK,EACL,UAAU,EACV,YAAY,EACb,MAAM,mBAAmB,CAAC;AAsB3B;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAEjD;;;;;OAKG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAE/C;;;;;;;;;OASG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAE9C;;;;;;;;OAQG;IACH,QAAQ,CAAC,2BAA2B,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC3D;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,CAAC;CACvC;AAED;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAY;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAW;IACpC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,kBAAkB,CAAK;IAC/B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsB;IAClD,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAS;IAC5C,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqB;IACtD,OAAO,CAAC,QAAQ,CAAC,2BAA2B,CAAS;IACrD,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA2B;IAC9D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA6C;gBAE5D,MAAM,EAAE,YAAY;IAkChC,OAAO,IAAI,IAAI;IAMf;;OAEG;IACH,IAAI,wBAAwB,IAAI,MAAM,CAErC;IAED;;OAEG;IACG,IAAI,CAAC,CAAC,EACV,UAAU,EAAE,UAAU,CAAC,CAAC,CAAC,EACzB,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EACf,OAAO,GAAE,YAAiB,GACzB,OAAO,CAAC,UAAU,CAAC;IAgItB;;;OAGG;YACW,gBAAgB;IAsB9B;;;;OAIG;YACW,gBAAgB;IA2D9B;;;;;;OAMG;YACW,eAAe;IAqC7B;;;;OAIG;YACW,kBAAkB;YAiClB,mBAAmB;CAelC"}
@@ -1,6 +1,12 @@
1
1
  import { TransportSendError } from '../errors/index.js';
2
2
  import { resolveTargetQueueName } from '../topology/index.js';
3
3
  import { createEnvelope } from '../types/index.js';
4
+ /**
5
+ * Default number of consecutive flush failures tolerated within a single
6
+ * flush pass before bailing out early and re-buffering the untried
7
+ * remainder.
8
+ */
9
+ const DEFAULT_MAX_CONSECUTIVE_FLUSH_FAILURES = 10;
4
10
  /**
5
11
  * Engine for fanning out events to subscribers.
6
12
  *
@@ -17,18 +23,53 @@ export class FanoutEngine {
17
23
  topology;
18
24
  defaultQueue;
19
25
  enqueuingCount = 0;
26
+ flushInFlightCount = 0;
27
+ retryBuffer = [];
28
+ maxRetryBufferSize;
29
+ maxRetryAttempts;
30
+ maxConsecutiveFlushFailures;
31
+ disposeOnConnected;
32
+ retryTimer;
20
33
  constructor(config) {
21
34
  this.transport = config.transport;
22
35
  this.schema = config.schema;
23
36
  this.hooks = config.hooks;
24
37
  this.topology = config.topology;
25
38
  this.defaultQueue = config.defaultQueue;
39
+ this.maxRetryBufferSize = config.maxRetryBufferSize ?? 5000;
40
+ this.maxRetryAttempts = config.maxRetryAttempts;
41
+ const maxConsecutiveFlushFailures = config.maxConsecutiveFlushFailures ??
42
+ DEFAULT_MAX_CONSECUTIVE_FLUSH_FAILURES;
43
+ this.maxConsecutiveFlushFailures =
44
+ maxConsecutiveFlushFailures > 0
45
+ ? maxConsecutiveFlushFailures
46
+ : Number.POSITIVE_INFINITY;
47
+ // If the transport supports it, add a callback to flush the retry buffer when the transport reconnects
48
+ // The returned value is a function to unsubscribe from the callback
49
+ this.disposeOnConnected = this.transport.onConnected?.(() => {
50
+ void this.flushRetryBuffer();
51
+ });
52
+ // Also flush periodically: onConnected only fires on connection-level
53
+ // reconnects, so it never fires when the transport stays connected but
54
+ // an individual publish keeps failing (e.g. broker nack, confirm timeout).
55
+ const retryIntervalMs = config.retryIntervalMs ?? 30_000;
56
+ if (retryIntervalMs > 0) {
57
+ this.retryTimer = setInterval(() => {
58
+ void this.flushRetryBuffer();
59
+ }, retryIntervalMs);
60
+ this.retryTimer.unref?.();
61
+ }
62
+ }
63
+ dispose() {
64
+ // Unsubscribe from the callback (that flushes the retry buffer when the transport reconnects)
65
+ this.disposeOnConnected?.();
66
+ clearInterval(this.retryTimer);
26
67
  }
27
68
  /**
28
69
  * Current count of events being enqueued.
29
70
  */
30
71
  get eventsBeingEnqueuedCount() {
31
- return this.enqueuingCount;
72
+ return this.enqueuingCount + this.flushInFlightCount;
32
73
  }
33
74
  /**
34
75
  * Sends an event to all registered subscribers.
@@ -68,11 +109,10 @@ export class FanoutEngine {
68
109
  delayMs: options.delayMs,
69
110
  });
70
111
  // Send to transport
112
+ const sendOptions = options.delayMs !== undefined ? { delay: options.delayMs } : undefined;
71
113
  this.enqueuingCount++;
72
114
  try {
73
- const usedTransport = await this.transport.send(qualifiedQueue, envelope, options.delayMs !== undefined
74
- ? { delay: options.delayMs }
75
- : undefined);
115
+ const usedTransport = await this.transport.send(qualifiedQueue, envelope, sendOptions);
76
116
  sent++;
77
117
  await this.hooks.onEnqueueSuccess({
78
118
  envelope,
@@ -82,17 +122,52 @@ export class FanoutEngine {
82
122
  }
83
123
  catch (error) {
84
124
  const cause = error instanceof Error ? error : new Error(String(error));
85
- const err = new TransportSendError(qualifiedQueue, cause);
86
- errors.push({
87
- subscriberName: subscriber.name,
88
- queue: qualifiedQueue,
89
- error: err,
90
- });
91
- await this.hooks.onEnqueueError({
92
- envelope,
93
- error: err,
94
- transport: this.transport.name,
95
- });
125
+ const shouldBuffer = options.buffer !== false;
126
+ if (shouldBuffer && this.retryBuffer.length < this.maxRetryBufferSize) {
127
+ this.retryBuffer.push({
128
+ queue: qualifiedQueue,
129
+ envelope,
130
+ sendOptions,
131
+ subscriberName: subscriber.name,
132
+ attempts: 0,
133
+ });
134
+ this.hooks.logger.warn(`[Matador] 🟡 Message for '${subscriber.name}' buffered for retry on reconnect.`, {
135
+ queue: qualifiedQueue,
136
+ subscriberName: subscriber.name,
137
+ bufferSize: this.retryBuffer.length,
138
+ maxBufferSize: this.maxRetryBufferSize,
139
+ });
140
+ if (options.throwOnBufferedFailure) {
141
+ const err = new TransportSendError(qualifiedQueue, cause);
142
+ // By adding the error, the main Send in Matador will throw
143
+ errors.push({
144
+ subscriberName: subscriber.name,
145
+ queue: qualifiedQueue,
146
+ error: err,
147
+ });
148
+ await this.hooks.onEnqueueError({
149
+ envelope,
150
+ error: err,
151
+ transport: this.transport.name,
152
+ });
153
+ }
154
+ }
155
+ else {
156
+ if (shouldBuffer) {
157
+ this.hooks.logger.error(`[Matador] 🔴 Retry buffer full (${this.maxRetryBufferSize}). Message for '${subscriber.name}' dropped and will not be retried.`);
158
+ }
159
+ const err = new TransportSendError(qualifiedQueue, cause);
160
+ errors.push({
161
+ subscriberName: subscriber.name,
162
+ queue: qualifiedQueue,
163
+ error: err,
164
+ });
165
+ await this.hooks.onEnqueueError({
166
+ envelope,
167
+ error: err,
168
+ transport: this.transport.name,
169
+ });
170
+ }
96
171
  }
97
172
  finally {
98
173
  this.enqueuingCount--;
@@ -105,6 +180,133 @@ export class FanoutEngine {
105
180
  errors,
106
181
  };
107
182
  }
183
+ /**
184
+ * Attempts a single buffered item during a flush pass
185
+ * @returns true if the attempt succeeded; false on failure
186
+ */
187
+ async attemptFlushItem(item) {
188
+ this.enqueuingCount++;
189
+ try {
190
+ const usedTransport = await this.transport.send(item.queue, item.envelope, item.sendOptions);
191
+ await this.hooks.onEnqueueSuccess({
192
+ envelope: item.envelope,
193
+ queue: item.queue,
194
+ transport: usedTransport,
195
+ });
196
+ return true;
197
+ }
198
+ catch (error) {
199
+ await this.handleFlushFailure(item, error);
200
+ return false;
201
+ }
202
+ finally {
203
+ this.enqueuingCount--;
204
+ }
205
+ }
206
+ /**
207
+ * Flushes the retry buffer
208
+ *
209
+ * This is called when the transport reconnects, and is used to retry any messages that were buffered while the transport was disconnected
210
+ */
211
+ async flushRetryBuffer() {
212
+ if (this.retryBuffer.length === 0)
213
+ return;
214
+ this.hooks.logger.info(`[Matador] ⏳ Flushing ${this.retryBuffer.length} buffered message(s)...`);
215
+ // Drain the buffer atomically so concurrent flush calls don't double-send.
216
+ const toFlush = this.retryBuffer.splice(0);
217
+ // Tracked separately from enqueuingCount so shutdown doesn't see a it falsely idle while items are being flushed
218
+ this.flushInFlightCount += toFlush.length;
219
+ let consecutiveFailures = 0;
220
+ try {
221
+ for (let i = 0; i < toFlush.length; i++) {
222
+ const item = toFlush[i];
223
+ if (!item) {
224
+ this.flushInFlightCount--;
225
+ continue;
226
+ }
227
+ const succeeded = await this.attemptFlushItem(item);
228
+ this.flushInFlightCount--;
229
+ // Small sleep to avoid overwhelming the transport
230
+ // Random sleep to avoid thundering herd (between 0 and 10ms)
231
+ const randomMs = Math.random() * 10;
232
+ await new Promise((resolve) => setTimeout(resolve, randomMs));
233
+ // A success means the transport is actually working, so it resets the consecutive failures streak
234
+ consecutiveFailures = succeeded ? 0 : consecutiveFailures + 1;
235
+ if (consecutiveFailures >= this.maxConsecutiveFlushFailures) {
236
+ // A run of failures this long is more likely a connection-level issue (broker down, slow, rejecting everything)
237
+ // So we stop early to avoid retrying 5k messages for nothing
238
+ const untried = toFlush.slice(i + 1);
239
+ this.flushInFlightCount -= untried.length;
240
+ if (untried.length > 0) {
241
+ await this.rebufferUntried(untried, consecutiveFailures);
242
+ }
243
+ break;
244
+ }
245
+ }
246
+ }
247
+ finally {
248
+ this.flushInFlightCount = 0;
249
+ }
250
+ const remaining = this.retryBuffer.length;
251
+ if (remaining > 0) {
252
+ this.hooks.logger.warn(`[Matador] 🟡 ${remaining} buffered message(s) could not be flushed; will retry later.`);
253
+ }
254
+ else {
255
+ this.hooks.logger.info('[Matador] 🟢 All buffered messages flushed successfully.');
256
+ }
257
+ }
258
+ /**
259
+ * Re-buffers messages that were never attempted because a flush pass
260
+ * stopped out early. Respects maxRetryBufferSize: concurrent sends can have
261
+ * refilled the buffer while this flush was running, so anything beyond
262
+ * remaining capacity is dropped and reported instead of silently growing
263
+ * the buffer past its cap.
264
+ */
265
+ async rebufferUntried(untried, consecutiveFailures) {
266
+ const capacity = Math.max(0, this.maxRetryBufferSize - this.retryBuffer.length);
267
+ const toRebuffer = untried.slice(0, capacity);
268
+ const dropped = untried.slice(capacity);
269
+ if (toRebuffer.length > 0) {
270
+ this.retryBuffer.unshift(...toRebuffer);
271
+ }
272
+ const droppedSuffix = dropped.length > 0 ? `, ${dropped.length} dropped (buffer full).` : '.';
273
+ this.hooks.logger.warn(`[Matador] 🟡 Stopping this flush pass after ${consecutiveFailures} consecutive failures; ${toRebuffer.length} untried message(s) re-buffered for the next attempt${droppedSuffix}`);
274
+ for (const item of dropped) {
275
+ this.hooks.logger.error(`[Matador] 🔴 Retry buffer full (${this.maxRetryBufferSize}). Message for '${item.subscriberName}' dropped and will not be retried.`);
276
+ const err = new TransportSendError(item.queue, new Error('Retry buffer full'));
277
+ await this.hooks.onEnqueueError({
278
+ envelope: item.envelope,
279
+ error: err,
280
+ transport: this.transport.name,
281
+ });
282
+ }
283
+ }
284
+ /**
285
+ * Handles a failed flush attempt for a single buffered item: drops it once
286
+ * maxRetryAttempts is exceeded, otherwise re-buffers it (unless the buffer
287
+ * is full, in which case it's dropped too).
288
+ */
289
+ async handleFlushFailure(item, error) {
290
+ item.attempts++;
291
+ const cause = error instanceof Error ? error : new Error(String(error));
292
+ const exceededAttempts = this.maxRetryAttempts !== undefined &&
293
+ item.attempts >= this.maxRetryAttempts;
294
+ if (!exceededAttempts &&
295
+ this.retryBuffer.length < this.maxRetryBufferSize) {
296
+ // Re-buffer on failure; it will be retried on the next reconnect or flush interval.
297
+ this.retryBuffer.push(item);
298
+ return;
299
+ }
300
+ if (exceededAttempts) {
301
+ this.hooks.logger.error(`[Matador] 🔴 Message for '${item.subscriberName}' exceeded max retry attempts (${this.maxRetryAttempts}) and will not be retried further.`);
302
+ }
303
+ const err = new TransportSendError(item.queue, cause);
304
+ await this.hooks.onEnqueueError({
305
+ envelope: item.envelope,
306
+ error: err,
307
+ transport: this.transport.name,
308
+ });
309
+ }
108
310
  async isSubscriberEnabled(subscriber) {
109
311
  if (!subscriber.enabled) {
110
312
  return true;