@namiml/sdk-core 3.4.0-dev.202605182046 → 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 +173 -22
- package/dist/index.d.ts +15 -4
- package/dist/index.mjs +173 -22
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
-
|
|
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() {
|
|
@@ -13685,19 +13849,6 @@ class NamiFlow extends BasicNamiFlow {
|
|
|
13685
13849
|
this.forward(nextStep.id);
|
|
13686
13850
|
}
|
|
13687
13851
|
}
|
|
13688
|
-
handleRemoteBack() {
|
|
13689
|
-
const step = this.currentFlowStep;
|
|
13690
|
-
if (step?.actions[NamiReservedActions.REMOTE_BACK]) {
|
|
13691
|
-
this.triggerActions(NamiReservedActions.REMOTE_BACK);
|
|
13692
|
-
return 'handler';
|
|
13693
|
-
}
|
|
13694
|
-
if (this.previousStepAvailable) {
|
|
13695
|
-
this.back();
|
|
13696
|
-
return 'back';
|
|
13697
|
-
}
|
|
13698
|
-
this.finished();
|
|
13699
|
-
return 'dismissed';
|
|
13700
|
-
}
|
|
13701
13852
|
backToPreviousScreenStep() {
|
|
13702
13853
|
if (this.previousFlowStep?.allow_back_to === false) {
|
|
13703
13854
|
logger.warn(`Not allowed to go back to ${this.previousFlowStep.id}`);
|
package/dist/index.d.ts
CHANGED
|
@@ -113,7 +113,6 @@ declare const NamiFlowStepType: {
|
|
|
113
113
|
readonly UNKNOWN: "unknown";
|
|
114
114
|
};
|
|
115
115
|
type NamiFlowStepType = (typeof NamiFlowStepType)[keyof typeof NamiFlowStepType];
|
|
116
|
-
type RemoteBackOutcome = 'handler' | 'back' | 'dismissed';
|
|
117
116
|
declare enum NamiFlowActionFunction {
|
|
118
117
|
NAVIGATE = "flowNav",
|
|
119
118
|
BACK = "flowPrev",
|
|
@@ -1430,7 +1429,6 @@ declare class NamiFlow extends BasicNamiFlow {
|
|
|
1430
1429
|
finished(): void;
|
|
1431
1430
|
back(): void;
|
|
1432
1431
|
next(): void;
|
|
1433
|
-
handleRemoteBack(): RemoteBackOutcome;
|
|
1434
1432
|
private backToPreviousScreenStep;
|
|
1435
1433
|
forward(stepId: string): void;
|
|
1436
1434
|
pause(): void;
|
|
@@ -2976,13 +2974,26 @@ declare class EntitlementRepository {
|
|
|
2976
2974
|
private invokeActiveEntitlementsHandler;
|
|
2977
2975
|
}
|
|
2978
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
|
+
|
|
2979
2987
|
declare class PaywallRepository {
|
|
2980
2988
|
static instance: PaywallRepository;
|
|
2981
2989
|
fetchPaywalls(): Promise<IPaywall[]>;
|
|
2982
2990
|
private getAnonymousPaywalls;
|
|
2983
2991
|
private getPaywalls;
|
|
2984
|
-
fetchPaywallByUrl(url: string): Promise<IPaywall
|
|
2985
|
-
fetchPaywallsByUrls(urls: string[]): Promise<
|
|
2992
|
+
fetchPaywallByUrl(url: string): Promise<CdnFetchResult<IPaywall>>;
|
|
2993
|
+
fetchPaywallsByUrls(urls: string[]): Promise<{
|
|
2994
|
+
paywalls: IPaywall[];
|
|
2995
|
+
retryCount: number;
|
|
2996
|
+
}>;
|
|
2986
2997
|
private validatePaywalls;
|
|
2987
2998
|
private fallbackData;
|
|
2988
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.
|
|
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
|
|
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
|
-
|
|
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() {
|
|
@@ -13683,19 +13847,6 @@ class NamiFlow extends BasicNamiFlow {
|
|
|
13683
13847
|
this.forward(nextStep.id);
|
|
13684
13848
|
}
|
|
13685
13849
|
}
|
|
13686
|
-
handleRemoteBack() {
|
|
13687
|
-
const step = this.currentFlowStep;
|
|
13688
|
-
if (step?.actions[NamiReservedActions.REMOTE_BACK]) {
|
|
13689
|
-
this.triggerActions(NamiReservedActions.REMOTE_BACK);
|
|
13690
|
-
return 'handler';
|
|
13691
|
-
}
|
|
13692
|
-
if (this.previousStepAvailable) {
|
|
13693
|
-
this.back();
|
|
13694
|
-
return 'back';
|
|
13695
|
-
}
|
|
13696
|
-
this.finished();
|
|
13697
|
-
return 'dismissed';
|
|
13698
|
-
}
|
|
13699
13850
|
backToPreviousScreenStep() {
|
|
13700
13851
|
if (this.previousFlowStep?.allow_back_to === false) {
|
|
13701
13852
|
logger.warn(`Not allowed to go back to ${this.previousFlowStep.id}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@namiml/sdk-core",
|
|
3
|
-
"version": "3.4.0-dev.
|
|
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",
|