@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/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;
@@ -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
- 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);
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 enqueue(report) {
1359
- if (!enabled) return;
1360
- const currentSize = await size();
1361
- if (currentSize >= maxQueueSize) {
1362
- queueLog.warn("Offline queue full \u2014 dropping report", { maxQueueSize });
1363
- 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
+ }
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
- if (backend === "localstorage" || backendType === "localstorage") {
1375
- 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 });
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 flush(client) {
1386
- if (!enabled) return { sent: 0, failed: 0 };
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 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;
1428
- // retrying re-serialises the multi-MB body every sync tick and wedges
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
- } else if (transient) {
1438
- const nextAttempts = rowAttempts(row) + 1;
1439
- if (nextAttempts >= MAX_DELIVERY_ATTEMPTS) {
1440
- await removeRow(backend, rowId);
1441
- 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", {
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
- row.attempts = nextAttempts;
1448
- const persisted = await persistRow(backend, row);
1449
- if (persisted) {
1450
- queueLog.debug("Offline queue: transient failure, will retry", {
1451
- id: rowId,
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
- failed++;
1465
- if (i < batch.length - 1) {
1466
- await sleep2(getBackoffDelay2(i));
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
- return lsRead().length;
1530
+ backendType = "localstorage";
1479
1531
  }
1480
1532
  }
1481
1533
  return lsRead().length;