layercache 1.2.9 → 1.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layercache",
3
- "version": "1.2.9",
3
+ "version": "1.3.1",
4
4
  "description": "Production-ready multi-layer caching for Node.js. Stack memory + Redis + disk behind one API with stampede prevention, tag invalidation, stale serving, and full observability.",
5
5
  "keywords": [
6
6
  "cache",
@@ -74,7 +74,15 @@
74
74
  "lint": "biome check .",
75
75
  "lint:fix": "biome check --write .",
76
76
  "bench:latency": "tsx benchmarks/latency.ts",
77
- "bench:stampede": "tsx benchmarks/stampede.ts"
77
+ "bench:stampede": "tsx benchmarks/stampede.ts",
78
+ "bench:direct": "tsx benchmarks/direct.ts",
79
+ "bench:http": "tsx benchmarks/http.ts",
80
+ "bench:edge": "tsx benchmarks/edge.ts",
81
+ "bench:slow-redis": "tsx benchmarks/slow-redis.ts",
82
+ "bench:memory-pressure": "tsx benchmarks/memory-pressure.ts",
83
+ "bench:queue-amplification": "tsx benchmarks/queue-amplification.ts",
84
+ "bench:multi-process-fanout": "tsx benchmarks/multi-process-fanout.ts",
85
+ "bench:all": "npm run bench:direct && npm run bench:edge && npm run bench:slow-redis && npm run bench:queue-amplification && npm run bench:http && npm run bench:multi-process-fanout"
78
86
  },
79
87
  "dependencies": {
80
88
  "@msgpack/msgpack": "^3.0.0",
@@ -90,8 +98,10 @@
90
98
  "@biomejs/biome": "^1.9.4",
91
99
  "@nestjs/common": "^11.1.0",
92
100
  "@nestjs/core": "^11.1.0",
101
+ "@types/autocannon": "^7.12.7",
93
102
  "@types/node": "^22.15.2",
94
103
  "@vitest/coverage-v8": "^4.1.2",
104
+ "autocannon": "^8.0.0",
95
105
  "ioredis": "^5.6.1",
96
106
  "ioredis-mock": "^8.13.0",
97
107
  "reflect-metadata": "^0.2.2",
@@ -741,15 +741,14 @@ function createInstanceId() {
741
741
  if (globalThis.crypto?.randomUUID) {
742
742
  return globalThis.crypto.randomUUID();
743
743
  }
744
- const bytes = new Uint8Array(16);
745
744
  if (globalThis.crypto?.getRandomValues) {
745
+ const bytes = new Uint8Array(16);
746
746
  globalThis.crypto.getRandomValues(bytes);
747
- } else {
748
- for (let i = 0; i < bytes.length; i += 1) {
749
- bytes[i] = Math.floor(Math.random() * 256);
750
- }
747
+ return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
751
748
  }
752
- return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
749
+ throw new Error(
750
+ "layercache requires a cryptographic random source. Neither crypto.randomUUID nor crypto.getRandomValues is available in this runtime."
751
+ );
753
752
  }
754
753
 
755
754
  // ../../src/internal/CacheStackGeneration.ts
@@ -1728,7 +1727,8 @@ var CircuitBreakerManager = class {
1728
1727
  }
1729
1728
  const remainingMs = state.openUntil - now;
1730
1729
  const remainingSecs = Math.ceil(remainingMs / 1e3);
1731
- throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
1730
+ const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
1731
+ throw new Error(`Circuit breaker is open for key "${displayKey}" (resets in ${remainingSecs}s).`);
1732
1732
  }
1733
1733
  recordFailure(key, options) {
1734
1734
  if (!options) {
@@ -2493,7 +2493,14 @@ var JsonSerializer = class {
2493
2493
  }
2494
2494
  deserialize(payload) {
2495
2495
  const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
2496
- return sanitizeStructuredData(JSON.parse(normalized), {
2496
+ let parsed;
2497
+ try {
2498
+ parsed = JSON.parse(normalized);
2499
+ } catch (error) {
2500
+ const message = error instanceof Error ? error.message : String(error);
2501
+ throw new Error(`JsonSerializer: failed to parse JSON payload: ${message}`);
2502
+ }
2503
+ return sanitizeStructuredData(parsed, {
2497
2504
  label: "JSON payload",
2498
2505
  maxDepth: 200,
2499
2506
  maxNodes: 1e4
@@ -2503,27 +2510,68 @@ var JsonSerializer = class {
2503
2510
 
2504
2511
  // ../../src/stampede/StampedeGuard.ts
2505
2512
  var StampedeGuard = class {
2506
- mutexes = /* @__PURE__ */ new Map();
2513
+ inFlight = /* @__PURE__ */ new Map();
2514
+ maxInFlight;
2515
+ entryTimeoutMs;
2516
+ constructor(options = {}) {
2517
+ this.maxInFlight = options.maxInFlight ?? 1e4;
2518
+ this.entryTimeoutMs = options.entryTimeoutMs;
2519
+ }
2507
2520
  async execute(key, task) {
2508
- const entry = this.getMutexEntry(key);
2521
+ const existing = this.inFlight.get(key);
2522
+ if (existing) {
2523
+ existing.references += 1;
2524
+ try {
2525
+ return await existing.promise;
2526
+ } finally {
2527
+ this.releaseEntry(key, existing);
2528
+ }
2529
+ }
2530
+ if (this.inFlight.size >= this.maxInFlight) {
2531
+ throw new Error(
2532
+ `StampedeGuard: in-flight limit of ${this.maxInFlight} exceeded. Rejecting new key to prevent memory exhaustion.`
2533
+ );
2534
+ }
2535
+ const taskPromise = Promise.resolve().then(task);
2536
+ const guardedPromise = this.entryTimeoutMs ? this.withTimeout(key, taskPromise, this.entryTimeoutMs) : taskPromise;
2537
+ const entry = {
2538
+ promise: guardedPromise,
2539
+ references: 1
2540
+ };
2541
+ this.inFlight.set(key, entry);
2509
2542
  try {
2510
- return await entry.mutex.runExclusive(task);
2543
+ return await entry.promise;
2511
2544
  } finally {
2512
- entry.references -= 1;
2513
- const current = this.mutexes.get(key);
2514
- if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
2515
- this.mutexes.delete(key);
2516
- }
2545
+ this.releaseEntry(key, entry);
2517
2546
  }
2518
2547
  }
2519
- getMutexEntry(key) {
2520
- let entry = this.mutexes.get(key);
2521
- if (!entry) {
2522
- entry = { mutex: new Mutex(), references: 0 };
2523
- this.mutexes.set(key, entry);
2548
+ withTimeout(key, promise, timeoutMs) {
2549
+ return new Promise((resolve, reject) => {
2550
+ const timer = setTimeout(() => {
2551
+ reject(
2552
+ new Error(
2553
+ `StampedeGuard: task for key "${key.slice(0, 64)}${key.length > 64 ? "..." : ""}" timed out after ${timeoutMs}ms.`
2554
+ )
2555
+ );
2556
+ }, timeoutMs);
2557
+ promise.then(
2558
+ (value) => {
2559
+ clearTimeout(timer);
2560
+ resolve(value);
2561
+ },
2562
+ (error) => {
2563
+ clearTimeout(timer);
2564
+ reject(error);
2565
+ }
2566
+ );
2567
+ });
2568
+ }
2569
+ releaseEntry(key, entry) {
2570
+ entry.references -= 1;
2571
+ const current = this.inFlight.get(key);
2572
+ if (current === entry && entry.references === 0) {
2573
+ this.inFlight.delete(key);
2524
2574
  }
2525
- entry.references += 1;
2526
- return entry;
2527
2575
  }
2528
2576
  };
2529
2577
 
@@ -2583,6 +2631,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
2583
2631
  const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
2584
2632
  this.ttlResolver = new TtlResolver({ maxProfileEntries });
2585
2633
  this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
2634
+ this.stampedeGuard = new StampedeGuard({
2635
+ maxInFlight: options.stampedeMaxInFlight,
2636
+ entryTimeoutMs: options.stampedeEntryTimeoutMs
2637
+ });
2586
2638
  this.currentGeneration = options.generation;
2587
2639
  if (options.publishSetInvalidation !== void 0) {
2588
2640
  console.warn(
@@ -2661,7 +2713,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2661
2713
  }
2662
2714
  layers;
2663
2715
  options;
2664
- stampedeGuard = new StampedeGuard();
2716
+ stampedeGuard;
2665
2717
  metricsCollector = new MetricsCollector();
2666
2718
  instanceId = createInstanceId();
2667
2719
  startup;
@@ -2738,7 +2790,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2738
2790
  if (!fetcher) {
2739
2791
  return null;
2740
2792
  }
2741
- return this.fetchWithGuards(normalizedKey, fetcher, options);
2793
+ return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
2742
2794
  }
2743
2795
  /**
2744
2796
  * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
@@ -2899,7 +2951,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
2899
2951
  return promise;
2900
2952
  }
2901
2953
  if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2902
- throw new Error(`mget received conflicting entries for key "${entry.key}".`);
2954
+ const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key;
2955
+ throw new Error(`mget received conflicting entries for key "${displayKey}".`);
2903
2956
  }
2904
2957
  return existing.promise;
2905
2958
  })
@@ -3229,12 +3282,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
3229
3282
  await this.handleInvalidationMessage(message);
3230
3283
  });
3231
3284
  }
3232
- async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
3285
+ async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
3233
3286
  const fetchTask = async () => {
3234
- const secondHit = await this.readFromLayers(key, options, "fresh-only");
3235
- if (secondHit.found) {
3236
- this.metricsCollector.increment("hits");
3237
- return secondHit.value;
3287
+ const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
3288
+ if (shouldRecheckFreshLayers) {
3289
+ const secondHit = await this.readFromLayers(key, options, "fresh-only");
3290
+ if (secondHit.found) {
3291
+ this.metricsCollector.increment("hits");
3292
+ return secondHit.value;
3293
+ }
3238
3294
  }
3239
3295
  return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
3240
3296
  };
@@ -3242,12 +3298,22 @@ var CacheStack = class extends import_node_events.EventEmitter {
3242
3298
  if (!this.options.singleFlightCoordinator) {
3243
3299
  return fetchTask();
3244
3300
  }
3245
- return this.options.singleFlightCoordinator.execute(
3246
- key,
3247
- this.resolveSingleFlightOptions(),
3248
- fetchTask,
3249
- () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
3250
- );
3301
+ try {
3302
+ return await this.options.singleFlightCoordinator.execute(
3303
+ key,
3304
+ this.resolveSingleFlightOptions(),
3305
+ fetchTask,
3306
+ () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
3307
+ );
3308
+ } catch (error) {
3309
+ if (!this.isGracefulDegradationEnabled()) {
3310
+ throw error;
3311
+ }
3312
+ this.metricsCollector.increment("degradedOperations");
3313
+ this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
3314
+ this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
3315
+ return fetchTask();
3316
+ }
3251
3317
  };
3252
3318
  if (this.options.stampedePrevention === false) {
3253
3319
  return singleFlightTask();
@@ -169,6 +169,8 @@ interface CacheStackOptions {
169
169
  logger?: CacheLogger | boolean;
170
170
  metrics?: boolean;
171
171
  stampedePrevention?: boolean;
172
+ stampedeMaxInFlight?: number;
173
+ stampedeEntryTimeoutMs?: number;
172
174
  invalidationBus?: InvalidationBus;
173
175
  tagIndex?: CacheTagIndex;
174
176
  generation?: number;
@@ -169,6 +169,8 @@ interface CacheStackOptions {
169
169
  logger?: CacheLogger | boolean;
170
170
  metrics?: boolean;
171
171
  stampedePrevention?: boolean;
172
+ stampedeMaxInFlight?: number;
173
+ stampedeEntryTimeoutMs?: number;
172
174
  invalidationBus?: InvalidationBus;
173
175
  tagIndex?: CacheTagIndex;
174
176
  generation?: number;
@@ -705,15 +705,14 @@ function createInstanceId() {
705
705
  if (globalThis.crypto?.randomUUID) {
706
706
  return globalThis.crypto.randomUUID();
707
707
  }
708
- const bytes = new Uint8Array(16);
709
708
  if (globalThis.crypto?.getRandomValues) {
709
+ const bytes = new Uint8Array(16);
710
710
  globalThis.crypto.getRandomValues(bytes);
711
- } else {
712
- for (let i = 0; i < bytes.length; i += 1) {
713
- bytes[i] = Math.floor(Math.random() * 256);
714
- }
711
+ return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
715
712
  }
716
- return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
713
+ throw new Error(
714
+ "layercache requires a cryptographic random source. Neither crypto.randomUUID nor crypto.getRandomValues is available in this runtime."
715
+ );
717
716
  }
718
717
 
719
718
  // ../../src/internal/CacheStackGeneration.ts
@@ -1692,7 +1691,8 @@ var CircuitBreakerManager = class {
1692
1691
  }
1693
1692
  const remainingMs = state.openUntil - now;
1694
1693
  const remainingSecs = Math.ceil(remainingMs / 1e3);
1695
- throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
1694
+ const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
1695
+ throw new Error(`Circuit breaker is open for key "${displayKey}" (resets in ${remainingSecs}s).`);
1696
1696
  }
1697
1697
  recordFailure(key, options) {
1698
1698
  if (!options) {
@@ -2457,7 +2457,14 @@ var JsonSerializer = class {
2457
2457
  }
2458
2458
  deserialize(payload) {
2459
2459
  const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
2460
- return sanitizeStructuredData(JSON.parse(normalized), {
2460
+ let parsed;
2461
+ try {
2462
+ parsed = JSON.parse(normalized);
2463
+ } catch (error) {
2464
+ const message = error instanceof Error ? error.message : String(error);
2465
+ throw new Error(`JsonSerializer: failed to parse JSON payload: ${message}`);
2466
+ }
2467
+ return sanitizeStructuredData(parsed, {
2461
2468
  label: "JSON payload",
2462
2469
  maxDepth: 200,
2463
2470
  maxNodes: 1e4
@@ -2467,27 +2474,68 @@ var JsonSerializer = class {
2467
2474
 
2468
2475
  // ../../src/stampede/StampedeGuard.ts
2469
2476
  var StampedeGuard = class {
2470
- mutexes = /* @__PURE__ */ new Map();
2477
+ inFlight = /* @__PURE__ */ new Map();
2478
+ maxInFlight;
2479
+ entryTimeoutMs;
2480
+ constructor(options = {}) {
2481
+ this.maxInFlight = options.maxInFlight ?? 1e4;
2482
+ this.entryTimeoutMs = options.entryTimeoutMs;
2483
+ }
2471
2484
  async execute(key, task) {
2472
- const entry = this.getMutexEntry(key);
2485
+ const existing = this.inFlight.get(key);
2486
+ if (existing) {
2487
+ existing.references += 1;
2488
+ try {
2489
+ return await existing.promise;
2490
+ } finally {
2491
+ this.releaseEntry(key, existing);
2492
+ }
2493
+ }
2494
+ if (this.inFlight.size >= this.maxInFlight) {
2495
+ throw new Error(
2496
+ `StampedeGuard: in-flight limit of ${this.maxInFlight} exceeded. Rejecting new key to prevent memory exhaustion.`
2497
+ );
2498
+ }
2499
+ const taskPromise = Promise.resolve().then(task);
2500
+ const guardedPromise = this.entryTimeoutMs ? this.withTimeout(key, taskPromise, this.entryTimeoutMs) : taskPromise;
2501
+ const entry = {
2502
+ promise: guardedPromise,
2503
+ references: 1
2504
+ };
2505
+ this.inFlight.set(key, entry);
2473
2506
  try {
2474
- return await entry.mutex.runExclusive(task);
2507
+ return await entry.promise;
2475
2508
  } finally {
2476
- entry.references -= 1;
2477
- const current = this.mutexes.get(key);
2478
- if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
2479
- this.mutexes.delete(key);
2480
- }
2509
+ this.releaseEntry(key, entry);
2481
2510
  }
2482
2511
  }
2483
- getMutexEntry(key) {
2484
- let entry = this.mutexes.get(key);
2485
- if (!entry) {
2486
- entry = { mutex: new Mutex(), references: 0 };
2487
- this.mutexes.set(key, entry);
2512
+ withTimeout(key, promise, timeoutMs) {
2513
+ return new Promise((resolve, reject) => {
2514
+ const timer = setTimeout(() => {
2515
+ reject(
2516
+ new Error(
2517
+ `StampedeGuard: task for key "${key.slice(0, 64)}${key.length > 64 ? "..." : ""}" timed out after ${timeoutMs}ms.`
2518
+ )
2519
+ );
2520
+ }, timeoutMs);
2521
+ promise.then(
2522
+ (value) => {
2523
+ clearTimeout(timer);
2524
+ resolve(value);
2525
+ },
2526
+ (error) => {
2527
+ clearTimeout(timer);
2528
+ reject(error);
2529
+ }
2530
+ );
2531
+ });
2532
+ }
2533
+ releaseEntry(key, entry) {
2534
+ entry.references -= 1;
2535
+ const current = this.inFlight.get(key);
2536
+ if (current === entry && entry.references === 0) {
2537
+ this.inFlight.delete(key);
2488
2538
  }
2489
- entry.references += 1;
2490
- return entry;
2491
2539
  }
2492
2540
  };
2493
2541
 
@@ -2547,6 +2595,10 @@ var CacheStack = class extends EventEmitter {
2547
2595
  const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
2548
2596
  this.ttlResolver = new TtlResolver({ maxProfileEntries });
2549
2597
  this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
2598
+ this.stampedeGuard = new StampedeGuard({
2599
+ maxInFlight: options.stampedeMaxInFlight,
2600
+ entryTimeoutMs: options.stampedeEntryTimeoutMs
2601
+ });
2550
2602
  this.currentGeneration = options.generation;
2551
2603
  if (options.publishSetInvalidation !== void 0) {
2552
2604
  console.warn(
@@ -2625,7 +2677,7 @@ var CacheStack = class extends EventEmitter {
2625
2677
  }
2626
2678
  layers;
2627
2679
  options;
2628
- stampedeGuard = new StampedeGuard();
2680
+ stampedeGuard;
2629
2681
  metricsCollector = new MetricsCollector();
2630
2682
  instanceId = createInstanceId();
2631
2683
  startup;
@@ -2702,7 +2754,7 @@ var CacheStack = class extends EventEmitter {
2702
2754
  if (!fetcher) {
2703
2755
  return null;
2704
2756
  }
2705
- return this.fetchWithGuards(normalizedKey, fetcher, options);
2757
+ return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
2706
2758
  }
2707
2759
  /**
2708
2760
  * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
@@ -2863,7 +2915,8 @@ var CacheStack = class extends EventEmitter {
2863
2915
  return promise;
2864
2916
  }
2865
2917
  if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2866
- throw new Error(`mget received conflicting entries for key "${entry.key}".`);
2918
+ const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key;
2919
+ throw new Error(`mget received conflicting entries for key "${displayKey}".`);
2867
2920
  }
2868
2921
  return existing.promise;
2869
2922
  })
@@ -3193,12 +3246,15 @@ var CacheStack = class extends EventEmitter {
3193
3246
  await this.handleInvalidationMessage(message);
3194
3247
  });
3195
3248
  }
3196
- async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
3249
+ async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
3197
3250
  const fetchTask = async () => {
3198
- const secondHit = await this.readFromLayers(key, options, "fresh-only");
3199
- if (secondHit.found) {
3200
- this.metricsCollector.increment("hits");
3201
- return secondHit.value;
3251
+ const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
3252
+ if (shouldRecheckFreshLayers) {
3253
+ const secondHit = await this.readFromLayers(key, options, "fresh-only");
3254
+ if (secondHit.found) {
3255
+ this.metricsCollector.increment("hits");
3256
+ return secondHit.value;
3257
+ }
3202
3258
  }
3203
3259
  return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
3204
3260
  };
@@ -3206,12 +3262,22 @@ var CacheStack = class extends EventEmitter {
3206
3262
  if (!this.options.singleFlightCoordinator) {
3207
3263
  return fetchTask();
3208
3264
  }
3209
- return this.options.singleFlightCoordinator.execute(
3210
- key,
3211
- this.resolveSingleFlightOptions(),
3212
- fetchTask,
3213
- () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
3214
- );
3265
+ try {
3266
+ return await this.options.singleFlightCoordinator.execute(
3267
+ key,
3268
+ this.resolveSingleFlightOptions(),
3269
+ fetchTask,
3270
+ () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
3271
+ );
3272
+ } catch (error) {
3273
+ if (!this.isGracefulDegradationEnabled()) {
3274
+ throw error;
3275
+ }
3276
+ this.metricsCollector.increment("degradedOperations");
3277
+ this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
3278
+ this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
3279
+ return fetchTask();
3280
+ }
3215
3281
  };
3216
3282
  if (this.options.stampedePrevention === false) {
3217
3283
  return singleFlightTask();