@upyo/pool 0.5.0-dev.164 → 0.5.0-dev.170

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 CHANGED
@@ -97,7 +97,7 @@ const transport = new PoolTransport({
97
97
  { transport: backupTransport, priority: 50 },
98
98
  { transport: emergencyTransport, priority: 10 },
99
99
  ],
100
- maxRetries: 3, // Try up to 3 transports
100
+ maxRetries: 2, // Retry up to 2 other transports
101
101
  });
102
102
 
103
103
  // Always tries primary first, only uses backup if primary fails
@@ -219,13 +219,13 @@ Configuration
219
219
 
220
220
  ### `PoolConfig`
221
221
 
222
- | Property | Type | Required | Default | Description |
223
- | ------------------- | --------------------------------------------------------------------------- | -------- | -------------------- | ----------------------------------------------------------------- |
224
- | `strategy` | `"round-robin" \| "weighted" \| "priority" \| "selector-based" \| Strategy` | Yes | | The strategy for selecting transports |
225
- | `transports` | `TransportEntry[]` | Yes | | Array of transport configurations |
226
- | `maxRetries` | `number` | No | Number of transports | Maximum retry attempts on failure |
227
- | `timeout` | `number` | No | | Timeout in milliseconds for each send attempt |
228
- | `continueOnSuccess` | `boolean` | No | `false` | Continue trying transports after success (selector strategy only) |
222
+ | Property | Type | Required | Default | Description |
223
+ | ------------------- | --------------------------------------------------------------------------- | -------- | --------------------------------- | ----------------------------------------------------------------- |
224
+ | `strategy` | `"round-robin" \| "weighted" \| "priority" \| "selector-based" \| Strategy` | Yes | | The strategy for selecting transports |
225
+ | `transports` | `TransportEntry[]` | Yes | | Array of transport configurations |
226
+ | `maxRetries` | `number` | No | Enough to try each transport once | Maximum retry attempts after the initial attempt |
227
+ | `timeout` | `number` | No | | Timeout in milliseconds for each send attempt |
228
+ | `continueOnSuccess` | `boolean` | No | `false` | Continue trying transports after success (selector strategy only) |
229
229
 
230
230
  ### `TransportEntry`
231
231
 
package/dist/index.cjs CHANGED
@@ -50,7 +50,7 @@ function createPoolConfig(config) {
50
50
  return {
51
51
  strategy: config.strategy,
52
52
  transports: resolvedTransports,
53
- maxRetries: config.maxRetries ?? enabledTransports.length,
53
+ maxRetries: config.maxRetries ?? enabledTransports.length - 1,
54
54
  timeout: config.timeout,
55
55
  continueOnSuccess: config.continueOnSuccess ?? false
56
56
  };
@@ -328,7 +328,8 @@ var PoolTransport = class {
328
328
  const attemptedIndices = /* @__PURE__ */ new Set();
329
329
  const errorMessages = [];
330
330
  const errors = [];
331
- for (let attempt = 0; attempt < this.config.maxRetries; attempt++) {
331
+ let checkCallerAbortBeforeFailure = false;
332
+ for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
332
333
  if (options?.signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
333
334
  const selection = this.strategy.select(message, this.config.transports, attemptedIndices);
334
335
  if (!selection) break;
@@ -340,7 +341,7 @@ var PoolTransport = class {
340
341
  try {
341
342
  receipt = await selection.entry.transport.send(message, sendOptions.options);
342
343
  } catch (error) {
343
- if (isAbortError(error) && sendOptions.abortedByCaller()) {
344
+ if (sendOptions.abortedByCaller() && isCallerAbort(error, options?.signal)) {
344
345
  abortedByCaller = true;
345
346
  throw error;
346
347
  }
@@ -351,24 +352,29 @@ var PoolTransport = class {
351
352
  if (receipt.successful) return receipt;
352
353
  errorMessages.push(...receipt.errorMessages);
353
354
  errors.push(...getReceiptErrors(receipt, selection.entry.transport.id));
355
+ checkCallerAbortBeforeFailure = true;
354
356
  } catch (error) {
355
- if (isAbortError(error) && abortedByCaller) throw error;
357
+ if (abortedByCaller && isCallerAbort(error, options?.signal)) throw error;
356
358
  const thrownErrors = getThrownReceiptErrors(error, selection.entry.transport.id);
357
359
  if (thrownErrors.length > 0) {
358
360
  errorMessages.push(...thrownErrors.map((item) => item.message));
359
361
  errors.push(...thrownErrors);
362
+ checkCallerAbortBeforeFailure = true;
360
363
  continue;
361
364
  }
362
365
  const timeoutMessage = "Transport send timed out.";
363
- const errorMessage = isAbortError(error) ? timeoutMessage : error instanceof Error ? error.message : String(error);
366
+ const abortError = isAbortError(error);
367
+ const errorMessage = abortError ? timeoutMessage : error instanceof Error ? error.message : String(error);
364
368
  errorMessages.push(errorMessage);
365
369
  errors.push((0, __upyo_core.createReceiptError)(errorMessage, {
366
370
  provider: selection.entry.transport.id,
367
- category: isAbortError(error) ? "timeout" : void 0,
368
- retryable: isAbortError(error) ? true : void 0
371
+ category: abortError ? "timeout" : void 0,
372
+ retryable: abortError ? true : void 0
369
373
  }));
374
+ checkCallerAbortBeforeFailure ||= !abortError;
370
375
  }
371
376
  }
377
+ if (checkCallerAbortBeforeFailure) options?.signal?.throwIfAborted();
372
378
  return (0, __upyo_core.createFailedReceipt)(errorMessages.length > 0 ? errorMessages : ["All transports failed to send the message."], {
373
379
  provider: "pool",
374
380
  errors: errors.length > 0 ? errors : void 0,
@@ -433,39 +439,50 @@ var PoolTransport = class {
433
439
  abortedByCaller: () => options?.signal?.aborted ?? false,
434
440
  cleanup: () => {}
435
441
  };
436
- const controller = new AbortController();
442
+ const timeoutController = new AbortController();
437
443
  let abortSource;
438
444
  const timeoutId = setTimeout(() => {
439
445
  abortSource ??= "timeout";
440
- controller.abort();
446
+ timeoutController.abort(createAbortError());
441
447
  }, this.config.timeout);
442
- let cleanup = () => clearTimeout(timeoutId);
448
+ let cleanupAbortSource = () => {};
449
+ let signal = timeoutController.signal;
450
+ let cleanupCombinedSignal = () => {};
443
451
  if (options?.signal) {
444
- const abort = () => {
452
+ const markCallerAbort = () => {
445
453
  abortSource ??= "caller";
446
454
  clearTimeout(timeoutId);
447
- controller.abort();
448
- };
449
- if (options.signal.aborted) abort();
450
- else options.signal.addEventListener("abort", abort, { once: true });
451
- cleanup = () => {
452
- clearTimeout(timeoutId);
453
- options.signal?.removeEventListener("abort", abort);
454
455
  };
456
+ if (options.signal.aborted) markCallerAbort();
457
+ else options.signal.addEventListener("abort", markCallerAbort, { once: true });
458
+ cleanupAbortSource = () => options.signal?.removeEventListener("abort", markCallerAbort);
459
+ const combinedSignal = (0, __upyo_core.combineSignals)(timeoutController.signal, options.signal);
460
+ signal = combinedSignal.signal;
461
+ cleanupCombinedSignal = combinedSignal.cleanup;
455
462
  }
456
- controller.signal.addEventListener("abort", () => {
463
+ timeoutController.signal.addEventListener("abort", () => {
457
464
  clearTimeout(timeoutId);
458
465
  });
459
466
  return {
460
467
  options: {
461
468
  ...options,
462
- signal: controller.signal
469
+ signal
463
470
  },
464
471
  abortedByCaller: () => abortSource === "caller",
465
- cleanup
472
+ cleanup: () => {
473
+ clearTimeout(timeoutId);
474
+ cleanupAbortSource();
475
+ cleanupCombinedSignal();
476
+ }
466
477
  };
467
478
  }
468
479
  };
480
+ function createAbortError() {
481
+ return new DOMException("The operation was aborted.", "AbortError");
482
+ }
483
+ function isCallerAbort(error, signal) {
484
+ return isAbortError(error) || error === signal?.reason;
485
+ }
469
486
  function getReceiptErrors(receipt, provider) {
470
487
  if (receipt.errors != null && receipt.errors.length > 0) return receipt.errors.map((error) => error.provider == null ? {
471
488
  ...error,
package/dist/index.d.cts CHANGED
@@ -95,8 +95,9 @@ interface PoolConfig<TProviderId extends string = string> {
95
95
  */
96
96
  readonly transports: readonly TransportEntry<TProviderId>[];
97
97
  /**
98
- * Maximum number of retry attempts when a transport fails.
99
- * Set to 0 to disable retries. Defaults to the number of transports.
98
+ * Maximum number of retry attempts after the initial send attempt fails.
99
+ * Set to 0 to disable retries. Defaults to enough retries to try every
100
+ * enabled transport once.
100
101
  */
101
102
  readonly maxRetries?: number;
102
103
  /**
package/dist/index.d.ts CHANGED
@@ -95,8 +95,9 @@ interface PoolConfig<TProviderId extends string = string> {
95
95
  */
96
96
  readonly transports: readonly TransportEntry<TProviderId>[];
97
97
  /**
98
- * Maximum number of retry attempts when a transport fails.
99
- * Set to 0 to disable retries. Defaults to the number of transports.
98
+ * Maximum number of retry attempts after the initial send attempt fails.
99
+ * Set to 0 to disable retries. Defaults to enough retries to try every
100
+ * enabled transport once.
100
101
  */
101
102
  readonly maxRetries?: number;
102
103
  /**
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createFailedReceipt, createReceiptError } from "@upyo/core";
1
+ import { combineSignals, createFailedReceipt, createReceiptError } from "@upyo/core";
2
2
 
3
3
  //#region src/config.ts
4
4
  /**
@@ -27,7 +27,7 @@ function createPoolConfig(config) {
27
27
  return {
28
28
  strategy: config.strategy,
29
29
  transports: resolvedTransports,
30
- maxRetries: config.maxRetries ?? enabledTransports.length,
30
+ maxRetries: config.maxRetries ?? enabledTransports.length - 1,
31
31
  timeout: config.timeout,
32
32
  continueOnSuccess: config.continueOnSuccess ?? false
33
33
  };
@@ -305,7 +305,8 @@ var PoolTransport = class {
305
305
  const attemptedIndices = /* @__PURE__ */ new Set();
306
306
  const errorMessages = [];
307
307
  const errors = [];
308
- for (let attempt = 0; attempt < this.config.maxRetries; attempt++) {
308
+ let checkCallerAbortBeforeFailure = false;
309
+ for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
309
310
  if (options?.signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
310
311
  const selection = this.strategy.select(message, this.config.transports, attemptedIndices);
311
312
  if (!selection) break;
@@ -317,7 +318,7 @@ var PoolTransport = class {
317
318
  try {
318
319
  receipt = await selection.entry.transport.send(message, sendOptions.options);
319
320
  } catch (error) {
320
- if (isAbortError(error) && sendOptions.abortedByCaller()) {
321
+ if (sendOptions.abortedByCaller() && isCallerAbort(error, options?.signal)) {
321
322
  abortedByCaller = true;
322
323
  throw error;
323
324
  }
@@ -328,24 +329,29 @@ var PoolTransport = class {
328
329
  if (receipt.successful) return receipt;
329
330
  errorMessages.push(...receipt.errorMessages);
330
331
  errors.push(...getReceiptErrors(receipt, selection.entry.transport.id));
332
+ checkCallerAbortBeforeFailure = true;
331
333
  } catch (error) {
332
- if (isAbortError(error) && abortedByCaller) throw error;
334
+ if (abortedByCaller && isCallerAbort(error, options?.signal)) throw error;
333
335
  const thrownErrors = getThrownReceiptErrors(error, selection.entry.transport.id);
334
336
  if (thrownErrors.length > 0) {
335
337
  errorMessages.push(...thrownErrors.map((item) => item.message));
336
338
  errors.push(...thrownErrors);
339
+ checkCallerAbortBeforeFailure = true;
337
340
  continue;
338
341
  }
339
342
  const timeoutMessage = "Transport send timed out.";
340
- const errorMessage = isAbortError(error) ? timeoutMessage : error instanceof Error ? error.message : String(error);
343
+ const abortError = isAbortError(error);
344
+ const errorMessage = abortError ? timeoutMessage : error instanceof Error ? error.message : String(error);
341
345
  errorMessages.push(errorMessage);
342
346
  errors.push(createReceiptError(errorMessage, {
343
347
  provider: selection.entry.transport.id,
344
- category: isAbortError(error) ? "timeout" : void 0,
345
- retryable: isAbortError(error) ? true : void 0
348
+ category: abortError ? "timeout" : void 0,
349
+ retryable: abortError ? true : void 0
346
350
  }));
351
+ checkCallerAbortBeforeFailure ||= !abortError;
347
352
  }
348
353
  }
354
+ if (checkCallerAbortBeforeFailure) options?.signal?.throwIfAborted();
349
355
  return createFailedReceipt(errorMessages.length > 0 ? errorMessages : ["All transports failed to send the message."], {
350
356
  provider: "pool",
351
357
  errors: errors.length > 0 ? errors : void 0,
@@ -410,39 +416,50 @@ var PoolTransport = class {
410
416
  abortedByCaller: () => options?.signal?.aborted ?? false,
411
417
  cleanup: () => {}
412
418
  };
413
- const controller = new AbortController();
419
+ const timeoutController = new AbortController();
414
420
  let abortSource;
415
421
  const timeoutId = setTimeout(() => {
416
422
  abortSource ??= "timeout";
417
- controller.abort();
423
+ timeoutController.abort(createAbortError());
418
424
  }, this.config.timeout);
419
- let cleanup = () => clearTimeout(timeoutId);
425
+ let cleanupAbortSource = () => {};
426
+ let signal = timeoutController.signal;
427
+ let cleanupCombinedSignal = () => {};
420
428
  if (options?.signal) {
421
- const abort = () => {
429
+ const markCallerAbort = () => {
422
430
  abortSource ??= "caller";
423
431
  clearTimeout(timeoutId);
424
- controller.abort();
425
- };
426
- if (options.signal.aborted) abort();
427
- else options.signal.addEventListener("abort", abort, { once: true });
428
- cleanup = () => {
429
- clearTimeout(timeoutId);
430
- options.signal?.removeEventListener("abort", abort);
431
432
  };
433
+ if (options.signal.aborted) markCallerAbort();
434
+ else options.signal.addEventListener("abort", markCallerAbort, { once: true });
435
+ cleanupAbortSource = () => options.signal?.removeEventListener("abort", markCallerAbort);
436
+ const combinedSignal = combineSignals(timeoutController.signal, options.signal);
437
+ signal = combinedSignal.signal;
438
+ cleanupCombinedSignal = combinedSignal.cleanup;
432
439
  }
433
- controller.signal.addEventListener("abort", () => {
440
+ timeoutController.signal.addEventListener("abort", () => {
434
441
  clearTimeout(timeoutId);
435
442
  });
436
443
  return {
437
444
  options: {
438
445
  ...options,
439
- signal: controller.signal
446
+ signal
440
447
  },
441
448
  abortedByCaller: () => abortSource === "caller",
442
- cleanup
449
+ cleanup: () => {
450
+ clearTimeout(timeoutId);
451
+ cleanupAbortSource();
452
+ cleanupCombinedSignal();
453
+ }
443
454
  };
444
455
  }
445
456
  };
457
+ function createAbortError() {
458
+ return new DOMException("The operation was aborted.", "AbortError");
459
+ }
460
+ function isCallerAbort(error, signal) {
461
+ return isAbortError(error) || error === signal?.reason;
462
+ }
446
463
  function getReceiptErrors(receipt, provider) {
447
464
  if (receipt.errors != null && receipt.errors.length > 0) return receipt.errors.map((error) => error.provider == null ? {
448
465
  ...error,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/pool",
3
- "version": "0.5.0-dev.164",
3
+ "version": "0.5.0-dev.170",
4
4
  "description": "Pool transport for Upyo email library—provides load balancing and failover for multiple email providers",
5
5
  "keywords": [
6
6
  "email",
@@ -56,12 +56,12 @@
56
56
  },
57
57
  "sideEffects": false,
58
58
  "peerDependencies": {
59
- "@upyo/core": "0.5.0-dev.164+5e283c64"
59
+ "@upyo/core": "0.5.0-dev.170+643994fd"
60
60
  },
61
61
  "devDependencies": {
62
62
  "tsdown": "^0.12.7",
63
63
  "typescript": "5.8.3",
64
- "@upyo/mock": "0.5.0-dev.164+5e283c64"
64
+ "@upyo/mock": "0.5.0-dev.170+643994fd"
65
65
  },
66
66
  "scripts": {
67
67
  "prepublish": "mise run --no-deps :build"