@mushi-mushi/core 1.27.0 → 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/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
- if (response.status >= 500 && retries > 0) {
187
- await sleep(getBackoffDelay(maxRetries - retries));
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 (response.status >= 500) cbRecordUnreachable();
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;
@@ -1054,12 +1069,27 @@ function openKeyDb() {
1054
1069
  return new Promise((resolve, reject) => {
1055
1070
  const req = indexedDB.open(KEY_DB, 1);
1056
1071
  req.onupgradeneeded = () => {
1072
+ try {
1073
+ const db = req.result;
1074
+ if (!db) return;
1075
+ if (!db.objectStoreNames.contains(KEY_STORE)) {
1076
+ db.createObjectStore(KEY_STORE);
1077
+ }
1078
+ } catch {
1079
+ }
1080
+ };
1081
+ req.onsuccess = () => {
1057
1082
  const db = req.result;
1058
- if (!db.objectStoreNames.contains(KEY_STORE)) {
1059
- db.createObjectStore(KEY_STORE);
1083
+ if (!db || !db.objectStoreNames.contains(KEY_STORE)) {
1084
+ try {
1085
+ db?.close();
1086
+ } catch {
1087
+ }
1088
+ reject(new Error("key object store unavailable"));
1089
+ return;
1060
1090
  }
1091
+ resolve(db);
1061
1092
  };
1062
- req.onsuccess = () => resolve(req.result);
1063
1093
  req.onerror = () => reject(req.error);
1064
1094
  });
1065
1095
  }
@@ -1149,6 +1179,8 @@ function createOfflineQueue(config = {}) {
1149
1179
  let syncCleanup = null;
1150
1180
  let flushInterval = null;
1151
1181
  let backendType = null;
1182
+ let inFlight = null;
1183
+ let followUp = null;
1152
1184
  async function wrapForStorage(report) {
1153
1185
  const queuedAt = (/* @__PURE__ */ new Date()).toISOString();
1154
1186
  if (!encryptAtRest) {
@@ -1202,12 +1234,28 @@ function createOfflineQueue(config = {}) {
1202
1234
  return new Promise((resolve, reject) => {
1203
1235
  const request = indexedDB.open(DB_NAME, DB_VERSION);
1204
1236
  request.onupgradeneeded = () => {
1237
+ try {
1238
+ const db = request.result;
1239
+ if (!db) return;
1240
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
1241
+ db.createObjectStore(STORE_NAME, { keyPath: "id" });
1242
+ }
1243
+ } catch {
1244
+ }
1245
+ };
1246
+ request.onsuccess = () => {
1205
1247
  const db = request.result;
1206
- if (!db.objectStoreNames.contains(STORE_NAME)) {
1207
- db.createObjectStore(STORE_NAME, { keyPath: "id" });
1248
+ if (!db || !db.objectStoreNames.contains(STORE_NAME)) {
1249
+ backendType = "localstorage";
1250
+ try {
1251
+ db?.close();
1252
+ } catch {
1253
+ }
1254
+ reject(new Error("queue object store unavailable"));
1255
+ return;
1208
1256
  }
1257
+ resolve(db);
1209
1258
  };
1210
- request.onsuccess = () => resolve(request.result);
1211
1259
  request.onerror = () => {
1212
1260
  backendType = "localstorage";
1213
1261
  reject(request.error);
@@ -1285,7 +1333,10 @@ function createOfflineQueue(config = {}) {
1285
1333
  }
1286
1334
  async function lsEnqueue(report) {
1287
1335
  const rows = lsRead();
1288
- rows.push(await wrapForStorage(report));
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);
1289
1340
  lsWrite(rows);
1290
1341
  }
1291
1342
  function lsDelete(id) {
@@ -1324,26 +1375,43 @@ function createOfflineQueue(config = {}) {
1324
1375
  return false;
1325
1376
  }
1326
1377
  }
1327
- async function enqueue(report) {
1328
- if (!enabled) return;
1329
- const currentSize = await size();
1330
- if (currentSize >= maxQueueSize) {
1331
- queueLog.warn("Offline queue full \u2014 dropping report", { maxQueueSize });
1332
- return;
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
+ }
1333
1387
  }
1388
+ return { backend: "localstorage", rows: lsRead() };
1389
+ }
1390
+ async function writeRow(report) {
1334
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
+ }
1335
1396
  if (backend === "indexeddb") {
1336
1397
  try {
1337
1398
  await idbEnqueue(report);
1338
- return;
1399
+ return backend;
1339
1400
  } catch {
1340
1401
  backendType = "localstorage";
1341
1402
  }
1342
1403
  }
1343
- if (backend === "localstorage" || backendType === "localstorage") {
1344
- await lsEnqueue(report);
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 });
1345
1412
  return;
1346
1413
  }
1414
+ await writeRow(report);
1347
1415
  }
1348
1416
  function getBackoffDelay2(attempt) {
1349
1417
  return Math.min(1e3 * 2 ** attempt + Math.random() * 500, MAX_BACKOFF_MS);
@@ -1351,19 +1419,9 @@ function createOfflineQueue(config = {}) {
1351
1419
  function sleep2(ms) {
1352
1420
  return new Promise((resolve) => setTimeout(resolve, ms));
1353
1421
  }
1354
- async function flush(client) {
1355
- if (!enabled) return { sent: 0, failed: 0 };
1356
- let rows;
1357
- const backend = detectBackend();
1358
- if (backend === "indexeddb") {
1359
- try {
1360
- rows = await idbGetAll();
1361
- } catch {
1362
- rows = lsRead();
1363
- }
1364
- } else {
1365
- rows = lsRead();
1366
- }
1422
+ async function runFlush(client) {
1423
+ const { backend, rows: storedRows } = await readRows();
1424
+ let rows = storedRows;
1367
1425
  const now2 = Date.now();
1368
1426
  const fresh = [];
1369
1427
  for (const row of rows) {
@@ -1392,59 +1450,84 @@ function createOfflineQueue(config = {}) {
1392
1450
  if (result.ok) {
1393
1451
  await removeRow(backend, rowId);
1394
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);
1395
1473
  } else {
1396
- 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;
1397
- // retrying re-serialises the multi-MB body every sync tick and wedges
1398
- // the queue (it matches neither permanent nor transient otherwise).
1399
- // SERIALIZE_FAILED (circular ref) is likewise unrecoverable on retry.
1400
- 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(
1401
- result.error.message
1402
- );
1403
- 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"));
1404
- if (permanent) {
1474
+ const nextAttempts = rowAttempts(row) + 1;
1475
+ if (nextAttempts >= MAX_DELIVERY_ATTEMPTS) {
1405
1476
  await removeRow(backend, rowId);
1406
- } else if (transient) {
1407
- const nextAttempts = rowAttempts(row) + 1;
1408
- if (nextAttempts >= MAX_DELIVERY_ATTEMPTS) {
1409
- await removeRow(backend, rowId);
1410
- queueLog.warn("Offline queue: giving up on report after repeated failures", {
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", {
1411
1487
  id: rowId,
1412
1488
  attempts: nextAttempts,
1413
- code: result.error?.code
1489
+ code: result.error?.code,
1490
+ classified: transient ? "transient" : "unclassified"
1414
1491
  });
1415
1492
  } else {
1416
- row.attempts = nextAttempts;
1417
- const persisted = await persistRow(backend, row);
1418
- if (persisted) {
1419
- queueLog.debug("Offline queue: transient failure, will retry", {
1420
- id: rowId,
1421
- attempts: nextAttempts,
1422
- code: result.error?.code
1423
- });
1424
- } else {
1425
- await removeRow(backend, rowId);
1426
- queueLog.warn("Offline queue: dropping report (attempt counter unpersistable)", {
1427
- id: rowId,
1428
- code: result.error?.code
1429
- });
1430
- }
1493
+ await removeRow(backend, rowId);
1494
+ queueLog.warn("Offline queue: dropping report (attempt counter unpersistable)", {
1495
+ id: rowId,
1496
+ code: result.error?.code
1497
+ });
1431
1498
  }
1432
1499
  }
1433
- failed++;
1434
- if (i < batch.length - 1) {
1435
- await sleep2(getBackoffDelay2(i));
1436
- }
1500
+ }
1501
+ if (i < batch.length - 1) {
1502
+ await sleep2(getBackoffDelay2(i));
1437
1503
  }
1438
1504
  }
1439
1505
  return { sent, failed };
1440
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
+ }
1441
1524
  async function size() {
1442
1525
  const backend = detectBackend();
1443
1526
  if (backend === "indexeddb") {
1444
1527
  try {
1445
1528
  return await idbSize();
1446
1529
  } catch {
1447
- return lsRead().length;
1530
+ backendType = "localstorage";
1448
1531
  }
1449
1532
  }
1450
1533
  return lsRead().length;