@teincfood/core 0.9.15 → 0.9.17

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.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Sync-failure reporter adapter.
3
+ *
4
+ * Core never depends on the observability SDK. The host app registers a
5
+ * reporter at boot (built on `@teincfood/observability`) and core forwards
6
+ * every failed command attempt so operators can see which device/app/user is
7
+ * struggling and go fix it.
8
+ */
9
+ export type SyncFailurePhase = "outbox" | "sync_jobs";
10
+ export interface SyncFailureReport {
11
+ /** Which queue produced the failure. */
12
+ phase: SyncFailurePhase;
13
+ business_id: string;
14
+ user_id?: string;
15
+ /** Stable per-install device id (matches the telemetry device). */
16
+ device_id?: string;
17
+ command_type: string;
18
+ command_id?: string;
19
+ entity_id?: string;
20
+ /** HTTP status when the failure came from a response. */
21
+ status?: number;
22
+ /** Backend/guard error code. */
23
+ code?: string;
24
+ message?: string;
25
+ attempts?: number;
26
+ /** True when the command was dead-lettered (no further auto-retry). */
27
+ terminal: boolean;
28
+ transport?: string;
29
+ }
30
+ export type SyncFailureReporter = (report: SyncFailureReport) => void;
31
+ export declare function setSyncFailureReporter(next: SyncFailureReporter | null): void;
32
+ /** Fire-and-forget; never throws, safe before a reporter is registered. */
33
+ export declare function reportSyncFailure(report: SyncFailureReport): void;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Sync-failure reporter adapter.
3
+ *
4
+ * Core never depends on the observability SDK. The host app registers a
5
+ * reporter at boot (built on `@teincfood/observability`) and core forwards
6
+ * every failed command attempt so operators can see which device/app/user is
7
+ * struggling and go fix it.
8
+ */
9
+ let reporter = null;
10
+ export function setSyncFailureReporter(next) {
11
+ reporter = next;
12
+ }
13
+ /** Fire-and-forget; never throws, safe before a reporter is registered. */
14
+ export function reportSyncFailure(report) {
15
+ if (!reporter)
16
+ return;
17
+ try {
18
+ reporter(report);
19
+ }
20
+ catch {
21
+ /* telemetry must never break sync */
22
+ }
23
+ }
package/dist/index.d.ts CHANGED
@@ -29,6 +29,8 @@ export * from "./utils/customer-display";
29
29
  export * from "./utils/order-filter";
30
30
  export { useReferenceData } from "./reference/hooks";
31
31
  export { setNotifyHandlers } from "./adapters/notify";
32
+ export { setSyncFailureReporter, reportSyncFailure, type SyncFailureReport, type SyncFailureReporter, type SyncFailurePhase, } from "./adapters/sync-failure";
33
+ export { noteRateLimited, isRateLimited, getRateLimitWaitMs, extractRetryAfterMs, shouldReportRateLimit, paceSync, waitForRateLimitClear, SYNC_MIN_INTERVAL_MS, resetRateLimitForTests, } from "./sync/rate-limit";
32
34
  export { walletService, riderService, fleetService, deliveryService, dashboardService, analyticsService, placesService } from "./adapters/services";
33
35
  export { buildMenuItemEnvelope, buildMenuCategoryEnvelope } from "./reference/menu-record";
34
36
  export { validateMenuItemInput, validateMenuCategoryInput, validateOrderCreateInput, validateInviteInput } from "./validation/offline";
package/dist/index.js CHANGED
@@ -30,6 +30,8 @@ export * from "./utils/customer-display";
30
30
  export * from "./utils/order-filter";
31
31
  export { useReferenceData } from "./reference/hooks";
32
32
  export { setNotifyHandlers } from "./adapters/notify";
33
+ export { setSyncFailureReporter, reportSyncFailure, } from "./adapters/sync-failure";
34
+ export { noteRateLimited, isRateLimited, getRateLimitWaitMs, extractRetryAfterMs, shouldReportRateLimit, paceSync, waitForRateLimitClear, SYNC_MIN_INTERVAL_MS, resetRateLimitForTests, } from "./sync/rate-limit";
33
35
  export { walletService, riderService, fleetService, deliveryService, dashboardService, analyticsService, placesService } from "./adapters/services";
34
36
  export { buildMenuItemEnvelope, buildMenuCategoryEnvelope } from "./reference/menu-record";
35
37
  export { validateMenuItemInput, validateMenuCategoryInput, validateOrderCreateInput, validateInviteInput } from "./validation/offline";
@@ -28,6 +28,14 @@ export function createAppQueryClient(onMutationError) {
28
28
  return false;
29
29
  }
30
30
  catch { }
31
+ // 429 is back-pressure, not a transient blip — retrying immediately
32
+ // deepens the storm. Background sync auto-resumes after the cooldown;
33
+ // queries refetch on reconnect/invalidation.
34
+ const status = error?.status;
35
+ const code = error?.code
36
+ ?? error?.response?.data?.error?.code;
37
+ if (status === 429 || code === "RATE_LIMIT_EXCEEDED")
38
+ return false;
31
39
  // Never retry explicit offline short-circuits (axios guard / adapters).
32
40
  const msg = error instanceof Error ? error.message : String(error ?? "");
33
41
  if (msg.startsWith("Offline") || error?.code === "OFFLINE")
@@ -14,6 +14,7 @@ import { menuService, businessService, rolesService } from "../adapters/services
14
14
  import { cacheImageBatch, pruneImageCache } from "../adapters/image-cache";
15
15
  import { getConnectivityState } from "../adapters/connectivity";
16
16
  import { canBusinessSync } from "../sync/sync-guard";
17
+ import { paceSync, isRateLimited } from "../sync/rate-limit";
17
18
  import { getReferenceData, getReferenceVersion, mergeReferenceData, writeServerReferenceData, } from "./operations";
18
19
  import { anchorServerTime } from "./entitlement-clock";
19
20
  import { isServerAuthoritativeKey } from "./entitlements";
@@ -90,6 +91,7 @@ class ReferenceSyncService {
90
91
  // endpoints, fall back to legacy full-fetch per key.
91
92
  let versions = null;
92
93
  try {
94
+ await paceSync();
93
95
  versions = await fetchReferenceVersions(options.businessId);
94
96
  }
95
97
  catch (error) {
@@ -98,7 +100,14 @@ class ReferenceSyncService {
98
100
  });
99
101
  }
100
102
  for (const key of keys) {
103
+ // Honour a global 429 cooldown: stop this pass; it resumes on the next
104
+ // trigger once the window clears.
105
+ if (isRateLimited()) {
106
+ result.skipped.push(key);
107
+ continue;
108
+ }
101
109
  try {
110
+ await paceSync();
102
111
  const synced = await this.syncKey(db, key, options, versions);
103
112
  if (synced) {
104
113
  result.synced.push(key);
@@ -24,6 +24,8 @@ declare class CommandQueueService {
24
24
  * Try to execute a single pending command through the current transport.
25
25
  */
26
26
  processCommand(command: PersistedCommand): Promise<void>;
27
+ /** Forward a failed attempt to the host's observability reporter. */
28
+ private reportFailure;
27
29
  /**
28
30
  * Mark a command permanently failed: loud toast + optimistic revert, then
29
31
  * replay continues to the next command. Shared by the exhausted-retry and
@@ -21,7 +21,9 @@ import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrd
21
21
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
22
22
  import { canBusinessSync } from "./sync-guard";
23
23
  import { backoffDelayMs } from "./backoff";
24
- const MAX_RETRIES = 5;
24
+ import { reportSyncFailure } from "../adapters/sync-failure";
25
+ import { paceSync, isRateLimited, getRateLimitWaitMs, extractRetryAfterMs, noteRateLimited, shouldReportRateLimit, } from "./rate-limit";
26
+ const MAX_ATTEMPTS = 8;
25
27
  /**
26
28
  * Stop a replay pass after this many consecutive transport-level failures
27
29
  * (network/timeout/5xx) so a dead server is not hammered once per queued
@@ -30,14 +32,8 @@ const MAX_RETRIES = 5;
30
32
  */
31
33
  const TRANSPORT_FAILURE_BREAKER = 3;
32
34
  /**
33
- * 4xx that deserve another chance: transient races and rate limits, never
34
- * permanent verdicts. Anything else 4xx (400 validation, 401 session,
35
- * 402 plan, 403 forbidden) fails fast with a toast. 409 ORDER_NUMBER_TAKEN
36
- * has its own regen path below and is not capped (every attempt mints a
37
- * new order number, so it is never a blind retry).
35
+ * In-memory next-eligible time per command (shared 2s→5min curve).
38
36
  */
39
- const RETRYABLE_4XX = new Set([404, 408, 409, 422, 429]);
40
- /** In-memory next-eligible time per command (shared 2s→5min curve). */
41
37
  const retryNotBefore = new Map();
42
38
  const QUEUED_STATUS_BY_COMMAND = {
43
39
  "order:accept": "accepted",
@@ -267,7 +263,7 @@ class CommandQueueService {
267
263
  timestamp: command.timestamp,
268
264
  payload: command.payload,
269
265
  };
270
- if (command.retry_count >= MAX_RETRIES) {
266
+ if (command.retry_count >= MAX_ATTEMPTS) {
271
267
  await updateCommandStatus(db, command.command_id, "failed", "Max retry count exceeded");
272
268
  return;
273
269
  }
@@ -280,6 +276,13 @@ class CommandQueueService {
280
276
  if (!this.syncEngine) {
281
277
  throw new Error("CommandQueueService: SyncEngine not initialized");
282
278
  }
279
+ // Global 429 cooldown: defer without consuming an attempt.
280
+ if (isRateLimited()) {
281
+ retryNotBefore.set(command.command_id, Date.now() + getRateLimitWaitMs());
282
+ throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "Rate limited, deferring queued command");
283
+ }
284
+ // Pace background replay so a deep queue drains at ~3 req/sec, not a burst.
285
+ await paceSync();
283
286
  await markCommandInFlight(db, command.command_id);
284
287
  try {
285
288
  const events = await this.syncEngine.execute(parsedCommand, { forReplay: true });
@@ -313,8 +316,31 @@ class CommandQueueService {
313
316
  }
314
317
  const apiErr = error;
315
318
  const message = error instanceof Error ? error.message : String(error);
316
- // 409 ORDER_NUMBER_TAKEN is retryable once with new RRR (exact backend code, any envelope)
317
319
  const code = extractApiErrorCode(error);
320
+ // 429: open the global cooldown, defer this entity, and DO NOT consume a
321
+ // retry attempt — the command is fine, the server is only busy.
322
+ if (apiErr?.status === 429) {
323
+ const retryAfterMs = extractRetryAfterMs(error);
324
+ noteRateLimited(retryAfterMs);
325
+ if (shouldReportRateLimit()) {
326
+ this.reportFailure(command, {
327
+ status: 429,
328
+ code: code ?? "RATE_LIMIT_EXCEEDED",
329
+ message,
330
+ attempts: command.retry_count,
331
+ terminal: false,
332
+ });
333
+ }
334
+ retryNotBefore.set(command.command_id, Date.now() + (retryAfterMs ?? getRateLimitWaitMs()));
335
+ await updateCommandStatus(db, command.command_id, "pending", `retry_429:${message}`);
336
+ syncLogger.warn("CommandQueueService", "Rate limited (429) — backing off", {
337
+ command_id: command.command_id,
338
+ command_type: command.command_type,
339
+ retry_after_ms: retryAfterMs,
340
+ });
341
+ throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, `Rate limited on ${command.command_type}, backing off`);
342
+ }
343
+ // 409 ORDER_NUMBER_TAKEN is retryable once with new RRR (exact backend code, any envelope)
318
344
  if (apiErr?.status === 409 && code === "ORDER_NUMBER_TAKEN") {
319
345
  if (command.command_type === "order:create") {
320
346
  try {
@@ -336,6 +362,13 @@ class CommandQueueService {
336
362
  catch { }
337
363
  }
338
364
  await updateCommandStatus(db, command.command_id, "failed", `order_number_taken:${message}`);
365
+ this.reportFailure(command, {
366
+ status: 409,
367
+ code: "ORDER_NUMBER_TAKEN",
368
+ message,
369
+ attempts: command.retry_count + 1,
370
+ terminal: true,
371
+ });
339
372
  throw error;
340
373
  }
341
374
  const status = typeof apiErr?.status === "number" ? apiErr.status : undefined;
@@ -352,19 +385,28 @@ class CommandQueueService {
352
385
  }
353
386
  catch { }
354
387
  }
355
- // Retryable 4xx (FIFO races, rate limits): back off on the shared
356
- // 2s→5min curve, then fail loudly after MAX_RETRIES. Throw a
357
- // transport-neutral deferral so replay STOPS here (FIFO ordering) —
358
- // rethrowing the 4xx itself would make the loop `continue` past it.
359
- if (status !== undefined && RETRYABLE_4XX.has(status)) {
388
+ // Every other failure is retryable on the shared 2s→5min backoff.
389
+ //
390
+ // 4xx (validation/permission/plan races) is bounded: a device stuck in
391
+ // a bad local state keeps retrying, then dead-letters so an operator is
392
+ // alerted and can visit the device. 5xx/network is an outage, not a
393
+ // device problem — it keeps retrying indefinitely so a long outage can
394
+ // never lose good orders. Every attempt is reported to observability.
395
+ const is4xxResponse = status !== undefined && status >= 400 && status < 500;
396
+ if (is4xxResponse) {
360
397
  const attempts = command.retry_count + 1;
361
- if (attempts >= MAX_RETRIES) {
362
- await this.failPermanently(db, command, message, `retryable 4xx exhausted after ${attempts} attempts`);
398
+ if (attempts >= MAX_ATTEMPTS) {
399
+ await this.failPermanently(db, command, message, `4xx exhausted after ${attempts} attempts`, {
400
+ status,
401
+ code,
402
+ attempts,
403
+ });
363
404
  return;
364
405
  }
406
+ this.reportFailure(command, { status, code, message, attempts, terminal: false });
365
407
  retryNotBefore.set(command.command_id, Date.now() + backoffDelayMs(command.retry_count));
366
408
  await updateCommandStatus(db, command.command_id, "pending", `retry_4xx:${message}`, true);
367
- syncLogger.warn("CommandQueueService", "Retryable 4xx — backing off", {
409
+ syncLogger.warn("CommandQueueService", "4xx — backing off", {
368
410
  command_id: command.command_id,
369
411
  command_type: command.command_type,
370
412
  status,
@@ -372,30 +414,37 @@ class CommandQueueService {
372
414
  });
373
415
  throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, `Retryable ${status} on ${command.command_type}, backing off`);
374
416
  }
375
- const isNonRetryable = status !== undefined &&
376
- status >= 400 &&
377
- status < 500;
378
- if (isNonRetryable) {
379
- await this.failPermanently(db, command, message, "permanent 4xx");
380
- // Do not throw — allow replay to continue to next command.
381
- return;
382
- }
383
- // Network/timeout/5xx: NOT a rejection — keep the command retryable
384
- // indefinitely (status back to pending, retry_count untouched) so a
385
- // long outage never dead-letters the outbox via MAX_RETRIES. Replay
386
- // stops here to preserve ordering; the next trigger retries.
417
+ // 5xx / network / timeout: keep the command retryable indefinitely
418
+ // (status back to pending, retry_count untouched) so a long outage never
419
+ // dead-letters the outbox. Replay defers this entity; the next trigger
420
+ // retries.
421
+ this.reportFailure(command, { status, code, message, attempts: command.retry_count, terminal: false });
387
422
  await updateCommandStatus(db, command.command_id, "pending", `network:${message}`);
388
423
  syncLogger.command(parsedCommand, "queued", { error: message, deferred: true });
389
424
  throw error;
390
425
  }
391
426
  }
427
+ /** Forward a failed attempt to the host's observability reporter. */
428
+ reportFailure(command, extra) {
429
+ reportSyncFailure({
430
+ phase: "outbox",
431
+ business_id: command.business_id,
432
+ user_id: command.user_id,
433
+ device_id: command.device_id,
434
+ command_type: command.command_type,
435
+ command_id: command.command_id,
436
+ entity_id: command.entity_id,
437
+ ...extra,
438
+ });
439
+ }
392
440
  /**
393
441
  * Mark a command permanently failed: loud toast + optimistic revert, then
394
442
  * replay continues to the next command. Shared by the exhausted-retry and
395
443
  * permanent-rejection paths.
396
444
  */
397
- async failPermanently(db, command, message, reason) {
445
+ async failPermanently(db, command, message, reason, extra) {
398
446
  await updateCommandStatus(db, command.command_id, "failed", `permanent:${message}`);
447
+ this.reportFailure(command, { ...extra, message, terminal: true });
399
448
  syncLogger.warn("CommandQueueService", `Queued command failed (${reason})`, {
400
449
  command_id: command.command_id,
401
450
  command_type: command.command_type,
@@ -25,6 +25,7 @@ import { openSyncDatabase } from "../db/connection";
25
25
  import { canBusinessSync } from "./sync-guard";
26
26
  import { TRANSITION_ERROR_CODES, TransitionError } from "./transition-errors";
27
27
  import { assertEntitledForCommand } from "../reference/entitlement-guard";
28
+ import { noteRateLimited, extractRetryAfterMs } from "./rate-limit";
28
29
  function withTimeout(promise, ms) {
29
30
  return new Promise((resolve, reject) => {
30
31
  const timer = setTimeout(() => {
@@ -243,6 +244,12 @@ export class SyncEngine {
243
244
  ? error.status
244
245
  : undefined;
245
246
  if (rejectionStatus !== undefined && rejectionStatus >= 400 && rejectionStatus < 500) {
247
+ // 429 is transient back-pressure, not a verdict: open the global
248
+ // cooldown so background sync pauses, then surface as usual (the
249
+ // outbox replay path classifies 429 separately and defers).
250
+ if (rejectionStatus === 429) {
251
+ noteRateLimited(extractRetryAfterMs(error));
252
+ }
246
253
  syncLogger.warn("SyncEngine", "Command rejected by server", {
247
254
  command_type: command.command_type,
248
255
  command_id: command.command_id,
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Global rate-limit gate + pacer for outgoing SYNC requests.
3
+ *
4
+ * Goal: drain the offline queue "naturally" — paced at roughly the rate of
5
+ * real user activity (~3 req/sec) so the backend never sees a burst that looks
6
+ * like an attack. When the server does throttle us (429), every sync path
7
+ * pauses until the Retry-After window elapses instead of hammering.
8
+ *
9
+ * User-initiated taps are NOT paced here; human pace is already the natural
10
+ * rate. Only background drains/replays/pulls/reference go through paceSync().
11
+ */
12
+ /** ~3 requests/second ceiling for background sync. */
13
+ export declare const SYNC_MIN_INTERVAL_MS = 333;
14
+ /** Record a 429: pause all sync until retryAfterMs (default 60s). */
15
+ export declare function noteRateLimited(retryAfterMs?: number): void;
16
+ /** Milliseconds remaining in the current cooldown (0 when not throttled). */
17
+ export declare function getRateLimitWaitMs(): number;
18
+ export declare function isRateLimited(): boolean;
19
+ /**
20
+ * Whether this throttle window has already been reported. The first 429 of a
21
+ * window reports (terminal:false); repeats stay silent so a storm does not
22
+ * spam observability.
23
+ */
24
+ export declare function shouldReportRateLimit(): boolean;
25
+ export declare function getRateLimitHitCount(): number;
26
+ /** Test/reset hook. */
27
+ export declare function resetRateLimitForTests(): void;
28
+ /**
29
+ * Extract Retry-After from an error: backend body
30
+ * `{ error: { retry_after_seconds } }` or a `retry-after` header.
31
+ */
32
+ export declare function extractRetryAfterMs(error: unknown): number | undefined;
33
+ /**
34
+ * Await a paced slot for the next background sync request. Serialized via a
35
+ * promise chain so concurrent callers still space out at SYNC_MIN_INTERVAL_MS.
36
+ */
37
+ export declare function paceSync(): Promise<void>;
38
+ /**
39
+ * Wait out an active cooldown. Returns false if we should abort this sync
40
+ * attempt instead of waiting (callers schedule their own retry).
41
+ */
42
+ export declare function waitForRateLimitClear(): Promise<boolean>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Global rate-limit gate + pacer for outgoing SYNC requests.
3
+ *
4
+ * Goal: drain the offline queue "naturally" — paced at roughly the rate of
5
+ * real user activity (~3 req/sec) so the backend never sees a burst that looks
6
+ * like an attack. When the server does throttle us (429), every sync path
7
+ * pauses until the Retry-After window elapses instead of hammering.
8
+ *
9
+ * User-initiated taps are NOT paced here; human pace is already the natural
10
+ * rate. Only background drains/replays/pulls/reference go through paceSync().
11
+ */
12
+ const DEFAULT_RETRY_AFTER_MS = 60000;
13
+ /** ~3 requests/second ceiling for background sync. */
14
+ export const SYNC_MIN_INTERVAL_MS = 333;
15
+ let rateLimitedUntil = 0;
16
+ let reportedWindowUntil = 0;
17
+ let rateLimitHitsInWindow = 0;
18
+ /** Record a 429: pause all sync until retryAfterMs (default 60s). */
19
+ export function noteRateLimited(retryAfterMs) {
20
+ const delay = retryAfterMs && retryAfterMs > 0 ? retryAfterMs : DEFAULT_RETRY_AFTER_MS;
21
+ const until = Date.now() + delay;
22
+ if (until > rateLimitedUntil)
23
+ rateLimitedUntil = until;
24
+ if (until > reportedWindowUntil) {
25
+ reportedWindowUntil = until;
26
+ rateLimitHitsInWindow = 0;
27
+ }
28
+ rateLimitHitsInWindow += 1;
29
+ }
30
+ /** Milliseconds remaining in the current cooldown (0 when not throttled). */
31
+ export function getRateLimitWaitMs() {
32
+ return Math.max(0, rateLimitedUntil - Date.now());
33
+ }
34
+ export function isRateLimited() {
35
+ return getRateLimitWaitMs() > 0;
36
+ }
37
+ /**
38
+ * Whether this throttle window has already been reported. The first 429 of a
39
+ * window reports (terminal:false); repeats stay silent so a storm does not
40
+ * spam observability.
41
+ */
42
+ export function shouldReportRateLimit() {
43
+ return rateLimitHitsInWindow <= 1;
44
+ }
45
+ export function getRateLimitHitCount() {
46
+ return rateLimitHitsInWindow;
47
+ }
48
+ /** Test/reset hook. */
49
+ export function resetRateLimitForTests() {
50
+ rateLimitedUntil = 0;
51
+ reportedWindowUntil = 0;
52
+ rateLimitHitsInWindow = 0;
53
+ lastSyncRequestAt = 0;
54
+ pacerChain = Promise.resolve();
55
+ }
56
+ /**
57
+ * Extract Retry-After from an error: backend body
58
+ * `{ error: { retry_after_seconds } }` or a `retry-after` header.
59
+ */
60
+ export function extractRetryAfterMs(error) {
61
+ if (!error || typeof error !== "object")
62
+ return undefined;
63
+ const e = error;
64
+ const body = (e.response?.data ?? e);
65
+ const secs = body?.error?.retry_after_seconds ?? body?.retry_after_seconds ?? undefined;
66
+ if (typeof secs === "number" && secs > 0)
67
+ return secs * 1000;
68
+ const header = e.response?.headers?.["retry-after"];
69
+ const headerSecs = typeof header === "string" ? Number(header) : NaN;
70
+ if (Number.isFinite(headerSecs) && headerSecs > 0)
71
+ return headerSecs * 1000;
72
+ return undefined;
73
+ }
74
+ // ── Pacer ────────────────────────────────────────────────────────────────────
75
+ let lastSyncRequestAt = 0;
76
+ let pacerChain = Promise.resolve();
77
+ function sleep(ms) {
78
+ return new Promise((resolve) => setTimeout(resolve, ms));
79
+ }
80
+ /**
81
+ * Await a paced slot for the next background sync request. Serialized via a
82
+ * promise chain so concurrent callers still space out at SYNC_MIN_INTERVAL_MS.
83
+ */
84
+ export function paceSync() {
85
+ const run = pacerChain.then(async () => {
86
+ const wait = Math.max(0, lastSyncRequestAt + SYNC_MIN_INTERVAL_MS - Date.now());
87
+ if (wait > 0)
88
+ await sleep(wait);
89
+ lastSyncRequestAt = Date.now();
90
+ });
91
+ pacerChain = run.catch(() => undefined);
92
+ return run;
93
+ }
94
+ /**
95
+ * Wait out an active cooldown. Returns false if we should abort this sync
96
+ * attempt instead of waiting (callers schedule their own retry).
97
+ */
98
+ export async function waitForRateLimitClear() {
99
+ const wait = getRateLimitWaitMs();
100
+ if (wait <= 0)
101
+ return true;
102
+ await sleep(wait);
103
+ return getRateLimitWaitMs() <= 0;
104
+ }
@@ -19,11 +19,14 @@ import { getTransitionPath, DELIVERY_COMMANDS } from "./transition-endpoints";
19
19
  import { getQueuedStatus } from "../utils/order-transitions";
20
20
  import { extractApiErrorCode } from "../utils/error";
21
21
  import { canBusinessSync, isAuthenticated } from "./sync-guard";
22
+ import { reportSyncFailure } from "../adapters/sync-failure";
23
+ import { getAppInstanceId } from "../adapters/devices";
24
+ import { paceSync, isRateLimited, getRateLimitWaitMs, extractRetryAfterMs, noteRateLimited, shouldReportRateLimit, } from "./rate-limit";
22
25
  const inflight = new Map();
23
26
  const retryTimers = new Map();
24
27
  const backoffTracker = new BackoffTracker();
25
- /** Blind 4xx retries stop here, loudly. 5xx/network retry indefinitely. */
26
- const MAX_SYNC_JOB_ATTEMPTS = 5;
28
+ /** Bounded 4xx retries stop here, loudly. 5xx/network retry indefinitely. */
29
+ const MAX_SYNC_JOB_ATTEMPTS = 8;
27
30
  function isOnline() {
28
31
  const s = getConnectivityState();
29
32
  return s.isOnline && s.isCloudReachable !== "unreachable";
@@ -37,15 +40,16 @@ function isOnlineForPhx() {
37
40
  const s = getConnectivityState();
38
41
  return s.isOnline && s.isCloudReachable === "reachable";
39
42
  }
40
- function scheduleRetry(businessId) {
43
+ function scheduleRetry(businessId, delayOverrideMs) {
41
44
  if (!canBusinessSync(businessId))
42
45
  return;
43
46
  if (retryTimers.has(businessId))
44
47
  return;
45
48
  // Shared backoff curve: 2s doubling to a 5min cap with jitter. Attempts
46
49
  // reset on any success (clearRetry) so long outages go quiet instead of
47
- // hammering a down backend every 30s.
48
- const delay = backoffTracker.next(businessId);
50
+ // hammering a down backend every 30s. A 429 cooldown overrides with the
51
+ // server-provided Retry-After window.
52
+ const delay = delayOverrideMs ?? backoffTracker.next(businessId);
49
53
  recordAttempt("sync_drain", `retry-scheduled delay=${delay}`);
50
54
  syncLogger.debug("SyncJobs", "scheduleRetry backoff", { businessId, delay });
51
55
  const t = setTimeout(() => {
@@ -65,6 +69,36 @@ function clearRetry(businessId) {
65
69
  }
66
70
  backoffTracker.reset(businessId);
67
71
  }
72
+ /** Best-effort device id for failure reports (never throws). */
73
+ function safeDeviceId() {
74
+ try {
75
+ return getAppInstanceId();
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ /** Forward a failed job attempt to the host's observability reporter. */
82
+ function reportJobFailure(businessId, row, extra) {
83
+ let userId;
84
+ try {
85
+ const p = JSON.parse(row.payload);
86
+ const candidate = p.user_id ?? p.user?.id;
87
+ if (typeof candidate === "string")
88
+ userId = candidate;
89
+ }
90
+ catch { }
91
+ reportSyncFailure({
92
+ phase: "sync_jobs",
93
+ business_id: businessId,
94
+ user_id: userId,
95
+ device_id: safeDeviceId(),
96
+ command_type: row.command_type,
97
+ command_id: row.id,
98
+ entity_id: row.entity_id,
99
+ ...extra,
100
+ });
101
+ }
68
102
  async function tryPhxPush(businessId, payload) {
69
103
  try {
70
104
  const socket = phoenixSocket.getChannel?.(`business:${businessId}`);
@@ -110,6 +144,8 @@ async function tryPhxPush(businessId, payload) {
110
144
  }
111
145
  async function tryRest(businessId, payload, commandType) {
112
146
  try {
147
+ // Pace background sync so a deep queue drains at ~3 req/sec, never a burst.
148
+ await paceSync();
113
149
  const http = getHttpClient();
114
150
  if (!http)
115
151
  return { ok: false };
@@ -210,6 +246,18 @@ async function tryRest(businessId, payload, commandType) {
210
246
  // Backend code via every envelope shape (core ApiError, axios, app ApiError).
211
247
  const bodyCode = extractApiErrorCode(e);
212
248
  const bodyMsg = err.response?.data?.error?.message;
249
+ // 429: open the global cooldown and let the caller reschedule — never count
250
+ // this as a job attempt (the command is fine; the server is busy).
251
+ if (status === 429) {
252
+ const retryAfterMs = extractRetryAfterMs(e);
253
+ noteRateLimited(retryAfterMs);
254
+ syncLogger.warn("SyncJobs", "Rate limited (429) — pausing sync", {
255
+ businessId,
256
+ commandType,
257
+ retryAfterMs,
258
+ });
259
+ return { ok: false, status, code: bodyCode ?? "RATE_LIMIT_EXCEEDED", message: bodyMsg ?? err.message, rateLimited: true, retryAfterMs };
260
+ }
213
261
  return {
214
262
  ok: false,
215
263
  status,
@@ -260,6 +308,13 @@ export async function drainOne(businessId, entityId) {
260
308
  syncLogger.debug("SyncJobs", "drainOne early-return gate", { businessId, entityId, onlineForRest, hasToken: isAuthenticated(), connectivity: state });
261
309
  return;
262
310
  }
311
+ // Global 429 cooldown: don't touch the network until the window clears.
312
+ if (isRateLimited()) {
313
+ const waitMs = getRateLimitWaitMs();
314
+ syncLogger.debug("SyncJobs", "drainOne deferred — rate limited", { businessId, entityId, waitMs });
315
+ scheduleRetry(businessId, waitMs);
316
+ return;
317
+ }
263
318
  const key = `${businessId}:${entityId}`;
264
319
  // Join an in-flight drain instead of skipping: concurrent drains (e.g. the
265
320
  // fire-and-forget drainOne after a write racing an awaited drainPending)
@@ -327,6 +382,22 @@ export async function drainOne(businessId, entityId) {
327
382
  catch { }
328
383
  return;
329
384
  }
385
+ // 429: global cooldown already opened by tryRest. Keep the job pending
386
+ // (no attempt consumed) and reschedule at the Retry-After window.
387
+ if (rest.rateLimited) {
388
+ if (shouldReportRateLimit()) {
389
+ reportJobFailure(businessId, row, {
390
+ status: 429,
391
+ code: rest.code ?? "RATE_LIMIT_EXCEEDED",
392
+ message: rest.message,
393
+ attempts: row.attempts,
394
+ terminal: false,
395
+ });
396
+ }
397
+ const waitMs = rest.retryAfterMs && rest.retryAfterMs > 0 ? rest.retryAfterMs : getRateLimitWaitMs();
398
+ scheduleRetry(businessId, waitMs);
399
+ return;
400
+ }
330
401
  // 3) Handle 409 RRR regen same UUID (POS create only, exact backend code)
331
402
  syncLogger.debug("SyncJobs", "check 409", { businessId, entityId, status: rest.status, code: rest.code });
332
403
  const is409 = rest.status === 409 && rest.code === "ORDER_NUMBER_TAKEN";
@@ -385,38 +456,13 @@ export async function drainOne(businessId, entityId) {
385
456
  syncLogger.debug("SyncJobs", "401 deferred until login", { businessId });
386
457
  return;
387
458
  }
388
- // Permanent 4xx: mark failed to stop the retry loop.
389
- // Retryable (bounded, 5 attempts): 401 (refresh owns it below), 408,
390
- // 409, 422, 429, any 404 (FIFO race: earlier command still in flight),
391
- // BUSINESS_CLOSED (pending-by-design). Everything else 4xx (400
392
- // validation, 402 plan, 403 forbidden) fails fast. Backend codes are
459
+ // Any 4xx is retryable with backoff (bounded); 5xx/network keeps retrying
460
+ // indefinitely. Every failed attempt is reported to observability so an
461
+ // operator can see the device/app/user and visit it. BUSINESS_CLOSED is a
462
+ // legitimate closed state (keep pending, re-pull hours). Backend codes are
393
463
  // exact (no message matching).
394
464
  const isBusinessClosed = rest.code === "BUSINESS_CLOSED" || rest.code === "HOURS_NOT_SYNCED";
395
- const is4xx = rest.status && rest.status >= 400 && rest.status < 500;
396
- const isRetryable4xx = rest.status === 401 || rest.status === 408 || rest.status === 409
397
- || rest.status === 422 || rest.status === 429 || rest.status === 404
398
- || isBusinessClosed;
399
- if (is4xx && !isRetryable4xx) {
400
- syncLogger.warn("SyncJobs", "Permanent 4xx — marking job failed to stop retry loop", { businessId, entityId, status: rest.status, code: rest.code, message: rest.message });
401
- try {
402
- await db.runAsync(`UPDATE sync_jobs SET status='failed', last_error=?, updated_at=? WHERE id=?;`, `${rest.status} ${rest.code ?? ""} ${rest.message ?? ""}`.trim(), new Date().toISOString(), row.id);
403
- }
404
- catch { }
405
- clearRetry(businessId);
406
- try {
407
- const { notifyError } = await import("../adapters/notify");
408
- notifyError(`Server rejected ${row.command_type}: ${rest.message ?? rest.code ?? rest.status}`);
409
- }
410
- catch { }
411
- // Retain optimistic row — pull will converge for transitions, create stays for manual retry.
412
- if (row.command_type !== "order:create") {
413
- try {
414
- void pullRemoteOrders(businessId).catch(() => { });
415
- }
416
- catch { }
417
- }
418
- return;
419
- }
465
+ const is4xx = !!rest.status && rest.status >= 400 && rest.status < 500;
420
466
  // Already-applied absorb for transitions: the server already moved the
421
467
  // order (duplicate tap, earlier drain won the race) — complete the job
422
468
  // instead of failing it. Server truth only (hlc-guarded pull), never the
@@ -432,39 +478,43 @@ export async function drainOne(businessId, entityId) {
432
478
  }
433
479
  catch { }
434
480
  }
435
- // Bounded retries: blind 4xx retries stop after 5 attempts with a loud
436
- // toast (attempts only increments on the keep-pending path below).
437
- if (is4xx && isRetryable4xx && (row.attempts ?? 0) + 1 >= 5) {
438
- syncLogger.warn("SyncJobs", "Retryable 4xx exhausted — marking job failed", { businessId, entityId, status: rest.status, code: rest.code, attempts: row.attempts });
481
+ if (isBusinessClosed) {
482
+ syncLogger.warn("SyncJobs", "Business closed — keeping pending for retry after hours sync", { businessId, entityId, code: rest.code });
483
+ reportJobFailure(businessId, row, { status: rest.status, code: rest.code, message: rest.message, attempts: row.attempts, terminal: false });
439
484
  try {
440
- await db.runAsync(`UPDATE sync_jobs SET status='failed', last_error=?, updated_at=? WHERE id=?;`, `${rest.status} ${rest.code ?? ""} ${rest.message ?? ""} (after ${MAX_SYNC_JOB_ATTEMPTS} attempts)`.trim(), new Date().toISOString(), row.id);
485
+ await db.runAsync(`UPDATE sync_jobs SET last_error=?, updated_at=? WHERE id=?;`, `${rest.status} ${rest.code ?? ""} ${rest.message ?? ""}`.trim(), new Date().toISOString(), row.id);
441
486
  }
442
487
  catch { }
443
- clearRetry(businessId);
488
+ // Re-pull reference so hours merge converges, then retry with backoff.
444
489
  try {
445
- const { notifyError } = await import("../adapters/notify");
446
- notifyError(`Server rejected ${row.command_type}: ${rest.message ?? rest.code ?? rest.status} (after retries)`);
490
+ const { referenceSyncService } = await import("../reference/service");
491
+ void referenceSyncService.sync({ businessId, keys: ["operating-hours", "business:profile"] }).catch(() => { });
447
492
  }
448
493
  catch { }
494
+ scheduleRetry(businessId);
449
495
  return;
450
496
  }
451
- if (isBusinessClosed) {
452
- syncLogger.warn("SyncJobs", "Business closed — keeping pending for retry after hours sync", { businessId, entityId, code: rest.code });
497
+ const attempts = (row.attempts ?? 0) + 1;
498
+ // Bounded 4xx: after MAX_SYNC_JOB_ATTEMPTS, dead-letter (retained) with a
499
+ // loud toast + observability report so an operator can visit the device.
500
+ if (is4xx && attempts >= MAX_SYNC_JOB_ATTEMPTS) {
501
+ syncLogger.warn("SyncJobs", "4xx exhausted — marking job failed", { businessId, entityId, status: rest.status, code: rest.code, attempts: row.attempts });
453
502
  try {
454
- await db.runAsync(`UPDATE sync_jobs SET last_error=?, updated_at=? WHERE id=?;`, `${rest.status} ${rest.code ?? ""} ${rest.message ?? ""}`.trim(), new Date().toISOString(), row.id);
503
+ await db.runAsync(`UPDATE sync_jobs SET status='failed', last_error=?, updated_at=? WHERE id=?;`, `${rest.status} ${rest.code ?? ""} ${rest.message ?? ""} (after ${MAX_SYNC_JOB_ATTEMPTS} attempts)`.trim(), new Date().toISOString(), row.id);
455
504
  }
456
505
  catch { }
457
- // Re-pull reference so hours merge converges, then retry with backoff.
506
+ reportJobFailure(businessId, row, { status: rest.status, code: rest.code, message: rest.message, attempts, terminal: true });
507
+ clearRetry(businessId);
458
508
  try {
459
- const { referenceSyncService } = await import("../reference/service");
460
- void referenceSyncService.sync({ businessId, keys: ["operating-hours", "business:profile"] }).catch(() => { });
509
+ const { notifyError } = await import("../adapters/notify");
510
+ notifyError(`Server rejected ${row.command_type}: ${rest.message ?? rest.code ?? rest.status} (after retries)`);
461
511
  }
462
512
  catch { }
463
- scheduleRetry(businessId);
464
513
  return;
465
514
  }
466
- // Keep pending for retry (5xx / network) — auto retry with backoff
515
+ // 4xx (bounded) or 5xx/network (indefinite): keep pending and back off.
467
516
  syncLogger.debug("SyncJobs", "drainOne kept pending for retry", { businessId, entityId, status: rest.status, code: rest.code });
517
+ reportJobFailure(businessId, row, { status: rest.status, code: rest.code, message: rest.message, attempts, terminal: false });
468
518
  try {
469
519
  await db.runAsync(`UPDATE sync_jobs SET attempts = attempts + 1, last_error = ?, updated_at = ? WHERE id = ?;`, `${rest.status ?? "network"} ${rest.code ?? ""} ${rest.message ?? ""}`.trim(), new Date().toISOString(), row.id);
470
520
  }
@@ -491,6 +541,11 @@ export async function pullRemoteOrders(businessId) {
491
541
  syncLogger.debug("SyncJobs", "pullRemoteOrders early-return gate", { businessId, onlineForRest, hasToken: isAuthenticated(), connectivity: getConnectivityState() });
492
542
  return;
493
543
  }
544
+ // Respect a global 429 cooldown for background pulls.
545
+ if (isRateLimited()) {
546
+ syncLogger.debug("SyncJobs", "pullRemoteOrders skipped — rate limited", { businessId });
547
+ return;
548
+ }
494
549
  try {
495
550
  const http = getHttpClient();
496
551
  if (!http) {
@@ -502,6 +557,7 @@ export async function pullRemoteOrders(businessId) {
502
557
  let totalPages = 1;
503
558
  do {
504
559
  syncLogger.debug("SyncJobs", "pullRemoteOrders GET start", { businessId, page });
560
+ await paceSync();
505
561
  const res = await http.get(`/businesses/${businessId}/orders`, { params: { page, page_size: 20 } });
506
562
  const payload = res.data;
507
563
  const data = payload?.data ?? res.data ?? [];
@@ -538,7 +594,8 @@ export async function pullRemoteOrders(businessId) {
538
594
  // Also pull recent deliveries (single page recent) for single business
539
595
  try {
540
596
  const http = getHttpClient();
541
- if (http) {
597
+ if (http && !isRateLimited()) {
598
+ await paceSync();
542
599
  const r2 = await http.get(`/businesses/${businessId}/deliveries`, { params: { page: 1, page_size: 20 } });
543
600
  const d = r2.data?.data ?? [];
544
601
  syncLogger.debug("SyncJobs", "pullRemoteDeliveries result", { businessId, dataLen: d.length });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.15",
3
+ "version": "0.9.17",
4
4
  "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",