@mushi-mushi/core 1.27.1 → 1.27.2
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 +1 -1
- package/dist/index.cjs +114 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +114 -62
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -109,6 +109,7 @@ var DEFAULT_TIMEOUT = 1e4;
|
|
|
109
109
|
var DEFAULT_MAX_RETRIES = 2;
|
|
110
110
|
var DEFAULT_CIRCUIT_THRESHOLD = 4;
|
|
111
111
|
var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
|
|
112
|
+
var MAX_INLINE_RETRY_WAIT_MS = 1e4;
|
|
112
113
|
function createApiClient(options) {
|
|
113
114
|
const {
|
|
114
115
|
projectId,
|
|
@@ -183,11 +184,15 @@ function createApiClient(options) {
|
|
|
183
184
|
`[Mushi] Credentials rejected (HTTP ${response.status}). Check your Project ID and API key scope (must be "report:write"). ` + where
|
|
184
185
|
);
|
|
185
186
|
}
|
|
186
|
-
|
|
187
|
-
|
|
187
|
+
const rateLimited = response.status === 429;
|
|
188
|
+
const unusable = response.status >= 500 || rateLimited;
|
|
189
|
+
const retryAfterMs = rateLimited ? parseRetryAfter(response.headers.get("Retry-After")) : null;
|
|
190
|
+
const canWaitInline = retryAfterMs === null || retryAfterMs <= MAX_INLINE_RETRY_WAIT_MS;
|
|
191
|
+
if (unusable && retries > 0 && canWaitInline) {
|
|
192
|
+
await sleep(retryAfterMs ?? getBackoffDelay(maxRetries - retries));
|
|
188
193
|
return request(method, path, body, retries - 1, internalKind, extraHeaders);
|
|
189
194
|
}
|
|
190
|
-
if (
|
|
195
|
+
if (unusable) cbRecordUnreachable();
|
|
191
196
|
else cbRecordReachable();
|
|
192
197
|
return {
|
|
193
198
|
ok: false,
|
|
@@ -205,7 +210,7 @@ function createApiClient(options) {
|
|
|
205
210
|
clearTimeout(timer);
|
|
206
211
|
if (retries > 0 && isRetryable(error)) {
|
|
207
212
|
await sleep(getBackoffDelay(maxRetries - retries));
|
|
208
|
-
return request(method, path, body, retries - 1, internalKind);
|
|
213
|
+
return request(method, path, body, retries - 1, internalKind, extraHeaders);
|
|
209
214
|
}
|
|
210
215
|
cbRecordUnreachable();
|
|
211
216
|
return {
|
|
@@ -495,6 +500,16 @@ function sleep(ms) {
|
|
|
495
500
|
function getBackoffDelay(attempt) {
|
|
496
501
|
return Math.min(1e3 * 2 ** attempt + Math.random() * 500, 1e4);
|
|
497
502
|
}
|
|
503
|
+
function parseRetryAfter(value, now2 = Date.now()) {
|
|
504
|
+
if (!value) return null;
|
|
505
|
+
const raw = value.trim();
|
|
506
|
+
if (!raw) return null;
|
|
507
|
+
const seconds = Number(raw);
|
|
508
|
+
if (Number.isFinite(seconds)) return seconds < 0 ? null : seconds * 1e3;
|
|
509
|
+
const at = Date.parse(raw);
|
|
510
|
+
if (Number.isNaN(at)) return null;
|
|
511
|
+
return Math.max(at - now2, 0);
|
|
512
|
+
}
|
|
498
513
|
function isRetryable(error) {
|
|
499
514
|
if (error instanceof DOMException && error.name === "AbortError") return true;
|
|
500
515
|
if (error instanceof TypeError) return true;
|
|
@@ -1164,6 +1179,8 @@ function createOfflineQueue(config = {}) {
|
|
|
1164
1179
|
let syncCleanup = null;
|
|
1165
1180
|
let flushInterval = null;
|
|
1166
1181
|
let backendType = null;
|
|
1182
|
+
let inFlight = null;
|
|
1183
|
+
let followUp = null;
|
|
1167
1184
|
async function wrapForStorage(report) {
|
|
1168
1185
|
const queuedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1169
1186
|
if (!encryptAtRest) {
|
|
@@ -1316,7 +1333,10 @@ function createOfflineQueue(config = {}) {
|
|
|
1316
1333
|
}
|
|
1317
1334
|
async function lsEnqueue(report) {
|
|
1318
1335
|
const rows = lsRead();
|
|
1319
|
-
|
|
1336
|
+
const row = await wrapForStorage(report);
|
|
1337
|
+
const idx = rows.findIndex((r) => r.id === report.id);
|
|
1338
|
+
if (idx >= 0) rows[idx] = row;
|
|
1339
|
+
else rows.push(row);
|
|
1320
1340
|
lsWrite(rows);
|
|
1321
1341
|
}
|
|
1322
1342
|
function lsDelete(id) {
|
|
@@ -1355,26 +1375,43 @@ function createOfflineQueue(config = {}) {
|
|
|
1355
1375
|
return false;
|
|
1356
1376
|
}
|
|
1357
1377
|
}
|
|
1358
|
-
async function
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
if (
|
|
1362
|
-
|
|
1363
|
-
|
|
1378
|
+
async function readRows() {
|
|
1379
|
+
const backend = detectBackend();
|
|
1380
|
+
if (backend === "none") return { backend, rows: [] };
|
|
1381
|
+
if (backend === "indexeddb") {
|
|
1382
|
+
try {
|
|
1383
|
+
return { backend, rows: await idbGetAll() };
|
|
1384
|
+
} catch {
|
|
1385
|
+
backendType = "localstorage";
|
|
1386
|
+
}
|
|
1364
1387
|
}
|
|
1388
|
+
return { backend: "localstorage", rows: lsRead() };
|
|
1389
|
+
}
|
|
1390
|
+
async function writeRow(report) {
|
|
1365
1391
|
const backend = detectBackend();
|
|
1392
|
+
if (backend === "none") {
|
|
1393
|
+
queueLog.debug("Offline queue: no storage backend, dropping report", { id: report.id });
|
|
1394
|
+
return backend;
|
|
1395
|
+
}
|
|
1366
1396
|
if (backend === "indexeddb") {
|
|
1367
1397
|
try {
|
|
1368
1398
|
await idbEnqueue(report);
|
|
1369
|
-
return;
|
|
1399
|
+
return backend;
|
|
1370
1400
|
} catch {
|
|
1371
1401
|
backendType = "localstorage";
|
|
1372
1402
|
}
|
|
1373
1403
|
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1404
|
+
await lsEnqueue(report);
|
|
1405
|
+
return "localstorage";
|
|
1406
|
+
}
|
|
1407
|
+
async function enqueue(report) {
|
|
1408
|
+
if (!enabled) return;
|
|
1409
|
+
const currentSize = await size();
|
|
1410
|
+
if (currentSize >= maxQueueSize) {
|
|
1411
|
+
queueLog.warn("Offline queue full \u2014 dropping report", { maxQueueSize });
|
|
1376
1412
|
return;
|
|
1377
1413
|
}
|
|
1414
|
+
await writeRow(report);
|
|
1378
1415
|
}
|
|
1379
1416
|
function getBackoffDelay2(attempt) {
|
|
1380
1417
|
return Math.min(1e3 * 2 ** attempt + Math.random() * 500, MAX_BACKOFF_MS);
|
|
@@ -1382,19 +1419,9 @@ function createOfflineQueue(config = {}) {
|
|
|
1382
1419
|
function sleep2(ms) {
|
|
1383
1420
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1384
1421
|
}
|
|
1385
|
-
async function
|
|
1386
|
-
|
|
1387
|
-
let rows;
|
|
1388
|
-
const backend = detectBackend();
|
|
1389
|
-
if (backend === "indexeddb") {
|
|
1390
|
-
try {
|
|
1391
|
-
rows = await idbGetAll();
|
|
1392
|
-
} catch {
|
|
1393
|
-
rows = lsRead();
|
|
1394
|
-
}
|
|
1395
|
-
} else {
|
|
1396
|
-
rows = lsRead();
|
|
1397
|
-
}
|
|
1422
|
+
async function runFlush(client) {
|
|
1423
|
+
const { backend, rows: storedRows } = await readRows();
|
|
1424
|
+
let rows = storedRows;
|
|
1398
1425
|
const now2 = Date.now();
|
|
1399
1426
|
const fresh = [];
|
|
1400
1427
|
for (const row of rows) {
|
|
@@ -1423,59 +1450,84 @@ function createOfflineQueue(config = {}) {
|
|
|
1423
1450
|
if (result.ok) {
|
|
1424
1451
|
await removeRow(backend, rowId);
|
|
1425
1452
|
sent++;
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
failed++;
|
|
1456
|
+
if (result.error?.code === "CIRCUIT_OPEN") {
|
|
1457
|
+
queueLog.debug("Offline queue: circuit open, deferring the rest of the batch", {
|
|
1458
|
+
id: rowId,
|
|
1459
|
+
remaining: batch.length - i
|
|
1460
|
+
});
|
|
1461
|
+
break;
|
|
1462
|
+
}
|
|
1463
|
+
const permanent = result.error?.code === "HTTP_400" || result.error?.code === "HTTP_413" || result.error?.code === "HTTP_422" || result.error?.code === "INGEST_ERROR" || result.error?.code === "VALIDATION_ERROR" || // A payload that exceeds the size guard will never shrink on its own;
|
|
1464
|
+
// retrying re-serialises the multi-MB body every sync tick and wedges
|
|
1465
|
+
// the queue (it matches neither permanent nor transient otherwise).
|
|
1466
|
+
// SERIALIZE_FAILED (circular ref) is likewise unrecoverable on retry.
|
|
1467
|
+
result.error?.code === "PAYLOAD_TOO_LARGE" || result.error?.code === "SERIALIZE_FAILED" || typeof result.error?.message === "string" && /invalid payload|description must be at least|validation/i.test(
|
|
1468
|
+
result.error.message
|
|
1469
|
+
);
|
|
1470
|
+
const transient = !permanent && (result.error?.code === "NETWORK_ERROR" || result.error?.code === "HTTP_403" || result.error?.code === "HTTP_429" || result.error?.code === "HTTP_502" || result.error?.code === "HTTP_503" || result.error?.code === "HTTP_504" || typeof result.error?.code === "string" && result.error.code.startsWith("HTTP_5"));
|
|
1471
|
+
if (permanent) {
|
|
1472
|
+
await removeRow(backend, rowId);
|
|
1426
1473
|
} else {
|
|
1427
|
-
const
|
|
1428
|
-
|
|
1429
|
-
// the queue (it matches neither permanent nor transient otherwise).
|
|
1430
|
-
// SERIALIZE_FAILED (circular ref) is likewise unrecoverable on retry.
|
|
1431
|
-
result.error?.code === "PAYLOAD_TOO_LARGE" || result.error?.code === "SERIALIZE_FAILED" || typeof result.error?.message === "string" && /invalid payload|description must be at least|validation/i.test(
|
|
1432
|
-
result.error.message
|
|
1433
|
-
);
|
|
1434
|
-
const transient = !permanent && (result.error?.code === "NETWORK_ERROR" || result.error?.code === "HTTP_403" || result.error?.code === "HTTP_429" || result.error?.code === "HTTP_502" || result.error?.code === "HTTP_503" || result.error?.code === "HTTP_504" || typeof result.error?.code === "string" && result.error.code.startsWith("HTTP_5"));
|
|
1435
|
-
if (permanent) {
|
|
1474
|
+
const nextAttempts = rowAttempts(row) + 1;
|
|
1475
|
+
if (nextAttempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
1436
1476
|
await removeRow(backend, rowId);
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1477
|
+
queueLog.warn("Offline queue: giving up on report after repeated failures", {
|
|
1478
|
+
id: rowId,
|
|
1479
|
+
attempts: nextAttempts,
|
|
1480
|
+
code: result.error?.code
|
|
1481
|
+
});
|
|
1482
|
+
} else {
|
|
1483
|
+
row.attempts = nextAttempts;
|
|
1484
|
+
const persisted = await persistRow(backend, row);
|
|
1485
|
+
if (persisted) {
|
|
1486
|
+
queueLog.debug("Offline queue: delivery failed, will retry", {
|
|
1442
1487
|
id: rowId,
|
|
1443
1488
|
attempts: nextAttempts,
|
|
1444
|
-
code: result.error?.code
|
|
1489
|
+
code: result.error?.code,
|
|
1490
|
+
classified: transient ? "transient" : "unclassified"
|
|
1445
1491
|
});
|
|
1446
1492
|
} else {
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
attempts: nextAttempts,
|
|
1453
|
-
code: result.error?.code
|
|
1454
|
-
});
|
|
1455
|
-
} else {
|
|
1456
|
-
await removeRow(backend, rowId);
|
|
1457
|
-
queueLog.warn("Offline queue: dropping report (attempt counter unpersistable)", {
|
|
1458
|
-
id: rowId,
|
|
1459
|
-
code: result.error?.code
|
|
1460
|
-
});
|
|
1461
|
-
}
|
|
1493
|
+
await removeRow(backend, rowId);
|
|
1494
|
+
queueLog.warn("Offline queue: dropping report (attempt counter unpersistable)", {
|
|
1495
|
+
id: rowId,
|
|
1496
|
+
code: result.error?.code
|
|
1497
|
+
});
|
|
1462
1498
|
}
|
|
1463
1499
|
}
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
}
|
|
1500
|
+
}
|
|
1501
|
+
if (i < batch.length - 1) {
|
|
1502
|
+
await sleep2(getBackoffDelay2(i));
|
|
1468
1503
|
}
|
|
1469
1504
|
}
|
|
1470
1505
|
return { sent, failed };
|
|
1471
1506
|
}
|
|
1507
|
+
function flush(client) {
|
|
1508
|
+
if (!enabled) return Promise.resolve({ sent: 0, failed: 0 });
|
|
1509
|
+
if (inFlight) {
|
|
1510
|
+
if (!followUp) {
|
|
1511
|
+
followUp = inFlight.catch(() => void 0).then(() => {
|
|
1512
|
+
followUp = null;
|
|
1513
|
+
return flush(client);
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
return followUp;
|
|
1517
|
+
}
|
|
1518
|
+
const pass = runFlush(client).finally(() => {
|
|
1519
|
+
if (inFlight === pass) inFlight = null;
|
|
1520
|
+
});
|
|
1521
|
+
inFlight = pass;
|
|
1522
|
+
return pass;
|
|
1523
|
+
}
|
|
1472
1524
|
async function size() {
|
|
1473
1525
|
const backend = detectBackend();
|
|
1474
1526
|
if (backend === "indexeddb") {
|
|
1475
1527
|
try {
|
|
1476
1528
|
return await idbSize();
|
|
1477
1529
|
} catch {
|
|
1478
|
-
|
|
1530
|
+
backendType = "localstorage";
|
|
1479
1531
|
}
|
|
1480
1532
|
}
|
|
1481
1533
|
return lsRead().length;
|