@namiml/sdk-core 3.4.0-dev.202605190129 → 3.4.0-dev.202605190929

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/dist/index.cjs CHANGED
@@ -98,7 +98,7 @@ const {
98
98
  // version — stamped by scripts/version.sh
99
99
  NAMI_SDK_VERSION: exports.NAMI_SDK_VERSION = "3.4.0",
100
100
  // full package version including dev suffix — stamped by scripts/version.sh
101
- NAMI_SDK_PACKAGE_VERSION: exports.NAMI_SDK_PACKAGE_VERSION = "3.4.0-dev.202605190129",
101
+ NAMI_SDK_PACKAGE_VERSION: exports.NAMI_SDK_PACKAGE_VERSION = "3.4.0-dev.202605190929",
102
102
  // environments
103
103
  PRODUCTION: exports.PRODUCTION = "production", DEVELOPMENT: exports.DEVELOPMENT = "development",
104
104
  // error messages
@@ -6849,7 +6849,7 @@ async function withRetry(url, options, timeout = exports.API_TIMEOUT_LIMIT, retr
6849
6849
  if (logTraffic && options?.body) {
6850
6850
  logger.debug(`[HTTP] Request body: ${options.body}`);
6851
6851
  }
6852
- const response = await timeoutRequest(url, options, timeout);
6852
+ const response = await timeoutRequest$1(url, options, timeout);
6853
6853
  if (!response.ok) {
6854
6854
  if (logTraffic) {
6855
6855
  const errorBody = await response.clone().text();
@@ -6879,7 +6879,7 @@ async function withRetry(url, options, timeout = exports.API_TIMEOUT_LIMIT, retr
6879
6879
  const response = await fetchWithRetry();
6880
6880
  return response.json();
6881
6881
  }
6882
- async function timeoutRequest(url, options = {}, timeout) {
6882
+ async function timeoutRequest$1(url, options = {}, timeout) {
6883
6883
  const controller = new AbortController();
6884
6884
  const timeoutId = setTimeout(() => controller.abort(), timeout);
6885
6885
  options["signal"] = controller.signal;
@@ -8309,6 +8309,161 @@ class ProductRepository {
8309
8309
  }
8310
8310
  ProductRepository.instance = new ProductRepository();
8311
8311
 
8312
+ const DEFAULTS = {
8313
+ maxAttempts: 3,
8314
+ baseDelayMs: 200,
8315
+ maxDelayMs: 2000,
8316
+ budgetMs: 5000,
8317
+ };
8318
+ const TRANSIENT_STATUSES = new Set([408, 425, 429]);
8319
+ const TRANSIENT_ERROR_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT"]);
8320
+ class NonRetryableHttpError extends Error {
8321
+ constructor(status, url) {
8322
+ super(`CDN fetch failed with non-retryable status ${status} for ${url}`);
8323
+ this.name = "NonRetryableHttpError";
8324
+ this.status = status;
8325
+ this.url = url;
8326
+ }
8327
+ }
8328
+ class RetryExhaustedError extends Error {
8329
+ constructor(url, attempts, lastStatus) {
8330
+ super(`CDN fetch exhausted ${attempts} attempts for ${url}${lastStatus !== undefined ? ` (last status ${lastStatus})` : ""}`);
8331
+ this.name = "RetryExhaustedError";
8332
+ this.url = url;
8333
+ this.attempts = attempts;
8334
+ this.lastStatus = lastStatus;
8335
+ }
8336
+ }
8337
+ /**
8338
+ * Classify a failure as transient (worth retrying) or not.
8339
+ *
8340
+ * Transient:
8341
+ * - HTTP 408 / 425 / 429 / any 5xx (504 Gateway Timeout from the CDN edge
8342
+ * is the motivating production case)
8343
+ * - AbortError from the request timeout
8344
+ * - Any TypeError thrown by fetch — covers Safari's "Load failed",
8345
+ * Chrome's "Failed to fetch", Firefox's "NetworkError ...", and
8346
+ * CORS-blocked responses from the CDN edge (a 504 without
8347
+ * Access-Control-Allow-Origin surfaces as a TypeError, not a Response)
8348
+ * - ECONNRESET/ECONNREFUSED/ENOTFOUND/ETIMEDOUT in Node test envs
8349
+ *
8350
+ * Non-transient: every other status (including 404 — a missing paywall is
8351
+ * genuinely gone; retrying just delays the inevitable failure).
8352
+ */
8353
+ function isTransientFailure(err, status) {
8354
+ if (typeof status === "number") {
8355
+ return TRANSIENT_STATUSES.has(status) || status >= 500;
8356
+ }
8357
+ if (err && typeof err === "object") {
8358
+ const e = err;
8359
+ if (e.name === "AbortError")
8360
+ return true;
8361
+ if (e.code && TRANSIENT_ERROR_CODES.has(e.code))
8362
+ return true;
8363
+ // fetch() throws TypeError for network-layer failures (DNS/TCP/TLS,
8364
+ // CORS-blocked, edge dropped). HTTP status failures arrive as fulfilled
8365
+ // responses with !response.ok — never as a thrown TypeError. So any
8366
+ // TypeError reaching this catch is by definition transient. Don't filter
8367
+ // on message text: Safari ("Load failed"), Chrome ("Failed to fetch"),
8368
+ // and Firefox ("NetworkError ...") all differ, and any future variation
8369
+ // would silently be classified non-transient.
8370
+ if (err instanceof TypeError)
8371
+ return true;
8372
+ }
8373
+ return false;
8374
+ }
8375
+ async function timeoutRequest(url, timeout) {
8376
+ const controller = new AbortController();
8377
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
8378
+ try {
8379
+ return await fetch(url, { signal: controller.signal });
8380
+ }
8381
+ finally {
8382
+ clearTimeout(timeoutId);
8383
+ }
8384
+ }
8385
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
8386
+ /**
8387
+ * Fetch a single CDN-hosted paywall with bounded retries on transient
8388
+ * failures. Exponential backoff (baseDelayMs * 2^n, capped at maxDelayMs)
8389
+ * with ±50% jitter; the whole loop is clamped to budgetMs wall-clock so
8390
+ * the parallel-fetch window can't balloon when one edge is degraded.
8391
+ *
8392
+ * Throws NonRetryableHttpError for hard 4xx (including 404), or
8393
+ * RetryExhaustedError when all attempts and/or the budget are spent.
8394
+ */
8395
+ async function fetchWithCdnRetry(url, opts = {}) {
8396
+ const maxAttempts = opts.maxAttempts ?? DEFAULTS.maxAttempts;
8397
+ const baseDelayMs = opts.baseDelayMs ?? DEFAULTS.baseDelayMs;
8398
+ const maxDelayMs = opts.maxDelayMs ?? DEFAULTS.maxDelayMs;
8399
+ const budgetMs = opts.budgetMs ?? DEFAULTS.budgetMs;
8400
+ const timeoutMs = opts.timeoutMs ?? exports.API_TIMEOUT_LIMIT;
8401
+ const randomFn = opts.randomFn ?? Math.random;
8402
+ const sleepFn = opts.sleepFn ?? defaultSleep;
8403
+ const logRequests = shouldLogHTTPRequests();
8404
+ const logTraffic = shouldLogHTTPTraffic();
8405
+ const start = Date.now();
8406
+ let attempt = 0;
8407
+ let lastStatus;
8408
+ let lastError;
8409
+ while (attempt < maxAttempts) {
8410
+ if (attempt > 0) {
8411
+ const elapsed = Date.now() - start;
8412
+ if (elapsed >= budgetMs)
8413
+ break;
8414
+ const baseDelay = Math.min(baseDelayMs * Math.pow(2, attempt - 1), maxDelayMs);
8415
+ // ±50% jitter: random in [0,1) → factor in [-0.5, 0.5)
8416
+ const jitter = baseDelay * (randomFn() - 0.5);
8417
+ const remaining = Math.max(0, budgetMs - elapsed);
8418
+ const sleepFor = Math.min(Math.max(0, baseDelay + jitter), remaining);
8419
+ await sleepFn(sleepFor);
8420
+ // If the sleep consumed the rest of the budget, don't fire another fetch.
8421
+ if (Date.now() - start >= budgetMs)
8422
+ break;
8423
+ }
8424
+ try {
8425
+ if (logRequests || logTraffic) {
8426
+ logger.debug(`[HTTP] GET ${url} (cdn attempt ${attempt + 1}/${maxAttempts})`);
8427
+ }
8428
+ const response = await timeoutRequest(url, timeoutMs);
8429
+ if (!response.ok) {
8430
+ if (logTraffic) {
8431
+ const errorBody = await response.clone().text();
8432
+ logger.debug(`[HTTP] Response ${response.status}: ${errorBody}`);
8433
+ }
8434
+ lastStatus = response.status;
8435
+ if (isTransientFailure(undefined, response.status)) {
8436
+ attempt++;
8437
+ continue;
8438
+ }
8439
+ throw new NonRetryableHttpError(response.status, url);
8440
+ }
8441
+ if (logTraffic) {
8442
+ const responseBody = await response.clone().text();
8443
+ logger.debug(`[HTTP] Response ${response.status}: ${responseBody}`);
8444
+ }
8445
+ const data = (await response.json());
8446
+ return { data, retryCount: attempt };
8447
+ }
8448
+ catch (err) {
8449
+ if (err instanceof NonRetryableHttpError)
8450
+ throw err;
8451
+ if (isTransientFailure(err)) {
8452
+ lastError = err;
8453
+ attempt++;
8454
+ continue;
8455
+ }
8456
+ throw err;
8457
+ }
8458
+ }
8459
+ // If we exited the loop without success but never saw a transient response,
8460
+ // fall back to surfacing the last raw error. Otherwise raise the canonical
8461
+ // exhausted-attempts signal with the last seen status for telemetry.
8462
+ if (lastStatus === undefined && lastError !== undefined)
8463
+ throw lastError;
8464
+ throw new RetryExhaustedError(url, attempt, lastStatus);
8465
+ }
8466
+
8312
8467
  class PaywallRepository {
8313
8468
  async fetchPaywalls() {
8314
8469
  const authDevice = storageService.getDevice();
@@ -8359,17 +8514,22 @@ class PaywallRepository {
8359
8514
  return data?.results || [];
8360
8515
  }
8361
8516
  async fetchPaywallByUrl(url) {
8362
- return withRetry(url);
8517
+ return fetchWithCdnRetry(url);
8363
8518
  }
8364
8519
  async fetchPaywallsByUrls(urls) {
8365
8520
  const uniqueUrls = [...new Set(urls)];
8366
8521
  const results = await Promise.allSettled(uniqueUrls.map((url) => this.fetchPaywallByUrl(url)));
8367
8522
  const paywalls = [];
8523
+ let retryCount = 0;
8368
8524
  for (const result of results) {
8369
8525
  if (result.status === "fulfilled") {
8370
- paywalls.push(result.value);
8526
+ paywalls.push(result.value.data);
8527
+ retryCount += result.value.retryCount;
8371
8528
  }
8372
8529
  else {
8530
+ // Note: an exhausted-failure path contributed retries we can't see
8531
+ // from the rejected promise, so retryCount under-reports failures.
8532
+ // Acceptable for the telemetry signal; revisit if full accounting matters.
8373
8533
  logger.error(`Failed to fetch individual paywall: ${result.reason}`);
8374
8534
  }
8375
8535
  }
@@ -8378,9 +8538,9 @@ class PaywallRepository {
8378
8538
  if (valid.length > 0) {
8379
8539
  storageService.setPaywalls(exports.API_PAYWALLS, valid);
8380
8540
  }
8381
- return valid;
8541
+ return { paywalls: valid, retryCount };
8382
8542
  }
8383
- return paywalls;
8543
+ return { paywalls, retryCount };
8384
8544
  }
8385
8545
  validatePaywalls(paywalls) {
8386
8546
  return paywalls.filter((paywall) => {
@@ -11856,10 +12016,13 @@ class NamiRefs {
11856
12016
  const useIndividual = !campaignRepo.useLegacyPaywallFetch &&
11857
12017
  CampaignRuleRepository.hasPaywallUrls(rawCampaigns);
11858
12018
  let paywalls;
12019
+ let retryCount = 0;
11859
12020
  const paywallsStartTime = Date.now();
11860
12021
  if (useIndividual) {
11861
12022
  const urls = CampaignRuleRepository.extractPaywallUrls(rawCampaigns);
11862
- paywalls = await paywallRepo.fetchPaywallsByUrls(urls);
12023
+ const result = await paywallRepo.fetchPaywallsByUrls(urls);
12024
+ paywalls = result.paywalls;
12025
+ retryCount = result.retryCount;
11863
12026
  }
11864
12027
  else {
11865
12028
  paywalls = await paywallRepo.fetchPaywalls();
@@ -11868,7 +12031,8 @@ class NamiRefs {
11868
12031
  const totalDuration = Date.now() - startTime;
11869
12032
  logger.info(`Paywall fetch telemetry: strategy=${useIndividual ? "individual" : "bulk"}, ` +
11870
12033
  `count=${paywalls.length}, total=${totalDuration}ms, ` +
11871
- `campaigns=${campaignRulesDuration}ms, paywalls=${paywallsDuration}ms`);
12034
+ `campaigns=${campaignRulesDuration}ms, paywalls=${paywallsDuration}ms` +
12035
+ `${useIndividual ? `, retry_count=${retryCount}` : ""}`);
11872
12036
  return campaignRepo.finalizeCampaignRules(rawCampaigns, paywalls);
11873
12037
  }
11874
12038
  reRenderPaywall() {
package/dist/index.d.ts CHANGED
@@ -2974,13 +2974,26 @@ declare class EntitlementRepository {
2974
2974
  private invokeActiveEntitlementsHandler;
2975
2975
  }
2976
2976
 
2977
+ /**
2978
+ * Result of a single per-paywall CDN fetch — carries the parsed body
2979
+ * alongside the number of retries it took to land. Aggregated upstream
2980
+ * by PaywallRepository.fetchPaywallsByUrls and surfaced in telemetry.
2981
+ */
2982
+ type CdnFetchResult<T> = {
2983
+ data: T;
2984
+ retryCount: number;
2985
+ };
2986
+
2977
2987
  declare class PaywallRepository {
2978
2988
  static instance: PaywallRepository;
2979
2989
  fetchPaywalls(): Promise<IPaywall[]>;
2980
2990
  private getAnonymousPaywalls;
2981
2991
  private getPaywalls;
2982
- fetchPaywallByUrl(url: string): Promise<IPaywall>;
2983
- fetchPaywallsByUrls(urls: string[]): Promise<IPaywall[]>;
2992
+ fetchPaywallByUrl(url: string): Promise<CdnFetchResult<IPaywall>>;
2993
+ fetchPaywallsByUrls(urls: string[]): Promise<{
2994
+ paywalls: IPaywall[];
2995
+ retryCount: number;
2996
+ }>;
2984
2997
  private validatePaywalls;
2985
2998
  private fallbackData;
2986
2999
  }
package/dist/index.mjs CHANGED
@@ -96,7 +96,7 @@ const {
96
96
  // version — stamped by scripts/version.sh
97
97
  NAMI_SDK_VERSION = "3.4.0",
98
98
  // full package version including dev suffix — stamped by scripts/version.sh
99
- NAMI_SDK_PACKAGE_VERSION = "3.4.0-dev.202605190129",
99
+ NAMI_SDK_PACKAGE_VERSION = "3.4.0-dev.202605190929",
100
100
  // environments
101
101
  PRODUCTION = "production", DEVELOPMENT = "development",
102
102
  // error messages
@@ -6847,7 +6847,7 @@ async function withRetry(url, options, timeout = API_TIMEOUT_LIMIT, retries = AP
6847
6847
  if (logTraffic && options?.body) {
6848
6848
  logger.debug(`[HTTP] Request body: ${options.body}`);
6849
6849
  }
6850
- const response = await timeoutRequest(url, options, timeout);
6850
+ const response = await timeoutRequest$1(url, options, timeout);
6851
6851
  if (!response.ok) {
6852
6852
  if (logTraffic) {
6853
6853
  const errorBody = await response.clone().text();
@@ -6877,7 +6877,7 @@ async function withRetry(url, options, timeout = API_TIMEOUT_LIMIT, retries = AP
6877
6877
  const response = await fetchWithRetry();
6878
6878
  return response.json();
6879
6879
  }
6880
- async function timeoutRequest(url, options = {}, timeout) {
6880
+ async function timeoutRequest$1(url, options = {}, timeout) {
6881
6881
  const controller = new AbortController();
6882
6882
  const timeoutId = setTimeout(() => controller.abort(), timeout);
6883
6883
  options["signal"] = controller.signal;
@@ -8307,6 +8307,161 @@ class ProductRepository {
8307
8307
  }
8308
8308
  ProductRepository.instance = new ProductRepository();
8309
8309
 
8310
+ const DEFAULTS = {
8311
+ maxAttempts: 3,
8312
+ baseDelayMs: 200,
8313
+ maxDelayMs: 2000,
8314
+ budgetMs: 5000,
8315
+ };
8316
+ const TRANSIENT_STATUSES = new Set([408, 425, 429]);
8317
+ const TRANSIENT_ERROR_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT"]);
8318
+ class NonRetryableHttpError extends Error {
8319
+ constructor(status, url) {
8320
+ super(`CDN fetch failed with non-retryable status ${status} for ${url}`);
8321
+ this.name = "NonRetryableHttpError";
8322
+ this.status = status;
8323
+ this.url = url;
8324
+ }
8325
+ }
8326
+ class RetryExhaustedError extends Error {
8327
+ constructor(url, attempts, lastStatus) {
8328
+ super(`CDN fetch exhausted ${attempts} attempts for ${url}${lastStatus !== undefined ? ` (last status ${lastStatus})` : ""}`);
8329
+ this.name = "RetryExhaustedError";
8330
+ this.url = url;
8331
+ this.attempts = attempts;
8332
+ this.lastStatus = lastStatus;
8333
+ }
8334
+ }
8335
+ /**
8336
+ * Classify a failure as transient (worth retrying) or not.
8337
+ *
8338
+ * Transient:
8339
+ * - HTTP 408 / 425 / 429 / any 5xx (504 Gateway Timeout from the CDN edge
8340
+ * is the motivating production case)
8341
+ * - AbortError from the request timeout
8342
+ * - Any TypeError thrown by fetch — covers Safari's "Load failed",
8343
+ * Chrome's "Failed to fetch", Firefox's "NetworkError ...", and
8344
+ * CORS-blocked responses from the CDN edge (a 504 without
8345
+ * Access-Control-Allow-Origin surfaces as a TypeError, not a Response)
8346
+ * - ECONNRESET/ECONNREFUSED/ENOTFOUND/ETIMEDOUT in Node test envs
8347
+ *
8348
+ * Non-transient: every other status (including 404 — a missing paywall is
8349
+ * genuinely gone; retrying just delays the inevitable failure).
8350
+ */
8351
+ function isTransientFailure(err, status) {
8352
+ if (typeof status === "number") {
8353
+ return TRANSIENT_STATUSES.has(status) || status >= 500;
8354
+ }
8355
+ if (err && typeof err === "object") {
8356
+ const e = err;
8357
+ if (e.name === "AbortError")
8358
+ return true;
8359
+ if (e.code && TRANSIENT_ERROR_CODES.has(e.code))
8360
+ return true;
8361
+ // fetch() throws TypeError for network-layer failures (DNS/TCP/TLS,
8362
+ // CORS-blocked, edge dropped). HTTP status failures arrive as fulfilled
8363
+ // responses with !response.ok — never as a thrown TypeError. So any
8364
+ // TypeError reaching this catch is by definition transient. Don't filter
8365
+ // on message text: Safari ("Load failed"), Chrome ("Failed to fetch"),
8366
+ // and Firefox ("NetworkError ...") all differ, and any future variation
8367
+ // would silently be classified non-transient.
8368
+ if (err instanceof TypeError)
8369
+ return true;
8370
+ }
8371
+ return false;
8372
+ }
8373
+ async function timeoutRequest(url, timeout) {
8374
+ const controller = new AbortController();
8375
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
8376
+ try {
8377
+ return await fetch(url, { signal: controller.signal });
8378
+ }
8379
+ finally {
8380
+ clearTimeout(timeoutId);
8381
+ }
8382
+ }
8383
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
8384
+ /**
8385
+ * Fetch a single CDN-hosted paywall with bounded retries on transient
8386
+ * failures. Exponential backoff (baseDelayMs * 2^n, capped at maxDelayMs)
8387
+ * with ±50% jitter; the whole loop is clamped to budgetMs wall-clock so
8388
+ * the parallel-fetch window can't balloon when one edge is degraded.
8389
+ *
8390
+ * Throws NonRetryableHttpError for hard 4xx (including 404), or
8391
+ * RetryExhaustedError when all attempts and/or the budget are spent.
8392
+ */
8393
+ async function fetchWithCdnRetry(url, opts = {}) {
8394
+ const maxAttempts = opts.maxAttempts ?? DEFAULTS.maxAttempts;
8395
+ const baseDelayMs = opts.baseDelayMs ?? DEFAULTS.baseDelayMs;
8396
+ const maxDelayMs = opts.maxDelayMs ?? DEFAULTS.maxDelayMs;
8397
+ const budgetMs = opts.budgetMs ?? DEFAULTS.budgetMs;
8398
+ const timeoutMs = opts.timeoutMs ?? API_TIMEOUT_LIMIT;
8399
+ const randomFn = opts.randomFn ?? Math.random;
8400
+ const sleepFn = opts.sleepFn ?? defaultSleep;
8401
+ const logRequests = shouldLogHTTPRequests();
8402
+ const logTraffic = shouldLogHTTPTraffic();
8403
+ const start = Date.now();
8404
+ let attempt = 0;
8405
+ let lastStatus;
8406
+ let lastError;
8407
+ while (attempt < maxAttempts) {
8408
+ if (attempt > 0) {
8409
+ const elapsed = Date.now() - start;
8410
+ if (elapsed >= budgetMs)
8411
+ break;
8412
+ const baseDelay = Math.min(baseDelayMs * Math.pow(2, attempt - 1), maxDelayMs);
8413
+ // ±50% jitter: random in [0,1) → factor in [-0.5, 0.5)
8414
+ const jitter = baseDelay * (randomFn() - 0.5);
8415
+ const remaining = Math.max(0, budgetMs - elapsed);
8416
+ const sleepFor = Math.min(Math.max(0, baseDelay + jitter), remaining);
8417
+ await sleepFn(sleepFor);
8418
+ // If the sleep consumed the rest of the budget, don't fire another fetch.
8419
+ if (Date.now() - start >= budgetMs)
8420
+ break;
8421
+ }
8422
+ try {
8423
+ if (logRequests || logTraffic) {
8424
+ logger.debug(`[HTTP] GET ${url} (cdn attempt ${attempt + 1}/${maxAttempts})`);
8425
+ }
8426
+ const response = await timeoutRequest(url, timeoutMs);
8427
+ if (!response.ok) {
8428
+ if (logTraffic) {
8429
+ const errorBody = await response.clone().text();
8430
+ logger.debug(`[HTTP] Response ${response.status}: ${errorBody}`);
8431
+ }
8432
+ lastStatus = response.status;
8433
+ if (isTransientFailure(undefined, response.status)) {
8434
+ attempt++;
8435
+ continue;
8436
+ }
8437
+ throw new NonRetryableHttpError(response.status, url);
8438
+ }
8439
+ if (logTraffic) {
8440
+ const responseBody = await response.clone().text();
8441
+ logger.debug(`[HTTP] Response ${response.status}: ${responseBody}`);
8442
+ }
8443
+ const data = (await response.json());
8444
+ return { data, retryCount: attempt };
8445
+ }
8446
+ catch (err) {
8447
+ if (err instanceof NonRetryableHttpError)
8448
+ throw err;
8449
+ if (isTransientFailure(err)) {
8450
+ lastError = err;
8451
+ attempt++;
8452
+ continue;
8453
+ }
8454
+ throw err;
8455
+ }
8456
+ }
8457
+ // If we exited the loop without success but never saw a transient response,
8458
+ // fall back to surfacing the last raw error. Otherwise raise the canonical
8459
+ // exhausted-attempts signal with the last seen status for telemetry.
8460
+ if (lastStatus === undefined && lastError !== undefined)
8461
+ throw lastError;
8462
+ throw new RetryExhaustedError(url, attempt, lastStatus);
8463
+ }
8464
+
8310
8465
  class PaywallRepository {
8311
8466
  async fetchPaywalls() {
8312
8467
  const authDevice = storageService.getDevice();
@@ -8357,17 +8512,22 @@ class PaywallRepository {
8357
8512
  return data?.results || [];
8358
8513
  }
8359
8514
  async fetchPaywallByUrl(url) {
8360
- return withRetry(url);
8515
+ return fetchWithCdnRetry(url);
8361
8516
  }
8362
8517
  async fetchPaywallsByUrls(urls) {
8363
8518
  const uniqueUrls = [...new Set(urls)];
8364
8519
  const results = await Promise.allSettled(uniqueUrls.map((url) => this.fetchPaywallByUrl(url)));
8365
8520
  const paywalls = [];
8521
+ let retryCount = 0;
8366
8522
  for (const result of results) {
8367
8523
  if (result.status === "fulfilled") {
8368
- paywalls.push(result.value);
8524
+ paywalls.push(result.value.data);
8525
+ retryCount += result.value.retryCount;
8369
8526
  }
8370
8527
  else {
8528
+ // Note: an exhausted-failure path contributed retries we can't see
8529
+ // from the rejected promise, so retryCount under-reports failures.
8530
+ // Acceptable for the telemetry signal; revisit if full accounting matters.
8371
8531
  logger.error(`Failed to fetch individual paywall: ${result.reason}`);
8372
8532
  }
8373
8533
  }
@@ -8376,9 +8536,9 @@ class PaywallRepository {
8376
8536
  if (valid.length > 0) {
8377
8537
  storageService.setPaywalls(API_PAYWALLS, valid);
8378
8538
  }
8379
- return valid;
8539
+ return { paywalls: valid, retryCount };
8380
8540
  }
8381
- return paywalls;
8541
+ return { paywalls, retryCount };
8382
8542
  }
8383
8543
  validatePaywalls(paywalls) {
8384
8544
  return paywalls.filter((paywall) => {
@@ -11854,10 +12014,13 @@ class NamiRefs {
11854
12014
  const useIndividual = !campaignRepo.useLegacyPaywallFetch &&
11855
12015
  CampaignRuleRepository.hasPaywallUrls(rawCampaigns);
11856
12016
  let paywalls;
12017
+ let retryCount = 0;
11857
12018
  const paywallsStartTime = Date.now();
11858
12019
  if (useIndividual) {
11859
12020
  const urls = CampaignRuleRepository.extractPaywallUrls(rawCampaigns);
11860
- paywalls = await paywallRepo.fetchPaywallsByUrls(urls);
12021
+ const result = await paywallRepo.fetchPaywallsByUrls(urls);
12022
+ paywalls = result.paywalls;
12023
+ retryCount = result.retryCount;
11861
12024
  }
11862
12025
  else {
11863
12026
  paywalls = await paywallRepo.fetchPaywalls();
@@ -11866,7 +12029,8 @@ class NamiRefs {
11866
12029
  const totalDuration = Date.now() - startTime;
11867
12030
  logger.info(`Paywall fetch telemetry: strategy=${useIndividual ? "individual" : "bulk"}, ` +
11868
12031
  `count=${paywalls.length}, total=${totalDuration}ms, ` +
11869
- `campaigns=${campaignRulesDuration}ms, paywalls=${paywallsDuration}ms`);
12032
+ `campaigns=${campaignRulesDuration}ms, paywalls=${paywallsDuration}ms` +
12033
+ `${useIndividual ? `, retry_count=${retryCount}` : ""}`);
11870
12034
  return campaignRepo.finalizeCampaignRules(rawCampaigns, paywalls);
11871
12035
  }
11872
12036
  reRenderPaywall() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namiml/sdk-core",
3
- "version": "3.4.0-dev.202605190129",
3
+ "version": "3.4.0-dev.202605190929",
4
4
  "description": "Platform-agnostic core for the Nami SDK — business logic, API, types, and state management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",