@teincfood/core 0.9.16 → 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.
package/dist/index.d.ts CHANGED
@@ -30,6 +30,7 @@ export * from "./utils/order-filter";
30
30
  export { useReferenceData } from "./reference/hooks";
31
31
  export { setNotifyHandlers } from "./adapters/notify";
32
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";
33
34
  export { walletService, riderService, fleetService, deliveryService, dashboardService, analyticsService, placesService } from "./adapters/services";
34
35
  export { buildMenuItemEnvelope, buildMenuCategoryEnvelope } from "./reference/menu-record";
35
36
  export { validateMenuItemInput, validateMenuCategoryInput, validateOrderCreateInput, validateInviteInput } from "./validation/offline";
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ export * from "./utils/order-filter";
31
31
  export { useReferenceData } from "./reference/hooks";
32
32
  export { setNotifyHandlers } from "./adapters/notify";
33
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";
34
35
  export { walletService, riderService, fleetService, deliveryService, dashboardService, analyticsService, placesService } from "./adapters/services";
35
36
  export { buildMenuItemEnvelope, buildMenuCategoryEnvelope } from "./reference/menu-record";
36
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);
@@ -22,6 +22,7 @@ import { getConnectivityState, subscribeConnectivity } from "../adapters/connect
22
22
  import { canBusinessSync } from "./sync-guard";
23
23
  import { backoffDelayMs } from "./backoff";
24
24
  import { reportSyncFailure } from "../adapters/sync-failure";
25
+ import { paceSync, isRateLimited, getRateLimitWaitMs, extractRetryAfterMs, noteRateLimited, shouldReportRateLimit, } from "./rate-limit";
25
26
  const MAX_ATTEMPTS = 8;
26
27
  /**
27
28
  * Stop a replay pass after this many consecutive transport-level failures
@@ -275,6 +276,13 @@ class CommandQueueService {
275
276
  if (!this.syncEngine) {
276
277
  throw new Error("CommandQueueService: SyncEngine not initialized");
277
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();
278
286
  await markCommandInFlight(db, command.command_id);
279
287
  try {
280
288
  const events = await this.syncEngine.execute(parsedCommand, { forReplay: true });
@@ -308,8 +316,31 @@ class CommandQueueService {
308
316
  }
309
317
  const apiErr = error;
310
318
  const message = error instanceof Error ? error.message : String(error);
311
- // 409 ORDER_NUMBER_TAKEN is retryable once with new RRR (exact backend code, any envelope)
312
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)
313
344
  if (apiErr?.status === 409 && code === "ORDER_NUMBER_TAKEN") {
314
345
  if (command.command_type === "order:create") {
315
346
  try {
@@ -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
+ }
@@ -21,6 +21,7 @@ import { extractApiErrorCode } from "../utils/error";
21
21
  import { canBusinessSync, isAuthenticated } from "./sync-guard";
22
22
  import { reportSyncFailure } from "../adapters/sync-failure";
23
23
  import { getAppInstanceId } from "../adapters/devices";
24
+ import { paceSync, isRateLimited, getRateLimitWaitMs, extractRetryAfterMs, noteRateLimited, shouldReportRateLimit, } from "./rate-limit";
24
25
  const inflight = new Map();
25
26
  const retryTimers = new Map();
26
27
  const backoffTracker = new BackoffTracker();
@@ -39,15 +40,16 @@ function isOnlineForPhx() {
39
40
  const s = getConnectivityState();
40
41
  return s.isOnline && s.isCloudReachable === "reachable";
41
42
  }
42
- function scheduleRetry(businessId) {
43
+ function scheduleRetry(businessId, delayOverrideMs) {
43
44
  if (!canBusinessSync(businessId))
44
45
  return;
45
46
  if (retryTimers.has(businessId))
46
47
  return;
47
48
  // Shared backoff curve: 2s doubling to a 5min cap with jitter. Attempts
48
49
  // reset on any success (clearRetry) so long outages go quiet instead of
49
- // hammering a down backend every 30s.
50
- 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);
51
53
  recordAttempt("sync_drain", `retry-scheduled delay=${delay}`);
52
54
  syncLogger.debug("SyncJobs", "scheduleRetry backoff", { businessId, delay });
53
55
  const t = setTimeout(() => {
@@ -142,6 +144,8 @@ async function tryPhxPush(businessId, payload) {
142
144
  }
143
145
  async function tryRest(businessId, payload, commandType) {
144
146
  try {
147
+ // Pace background sync so a deep queue drains at ~3 req/sec, never a burst.
148
+ await paceSync();
145
149
  const http = getHttpClient();
146
150
  if (!http)
147
151
  return { ok: false };
@@ -242,6 +246,18 @@ async function tryRest(businessId, payload, commandType) {
242
246
  // Backend code via every envelope shape (core ApiError, axios, app ApiError).
243
247
  const bodyCode = extractApiErrorCode(e);
244
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
+ }
245
261
  return {
246
262
  ok: false,
247
263
  status,
@@ -292,6 +308,13 @@ export async function drainOne(businessId, entityId) {
292
308
  syncLogger.debug("SyncJobs", "drainOne early-return gate", { businessId, entityId, onlineForRest, hasToken: isAuthenticated(), connectivity: state });
293
309
  return;
294
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
+ }
295
318
  const key = `${businessId}:${entityId}`;
296
319
  // Join an in-flight drain instead of skipping: concurrent drains (e.g. the
297
320
  // fire-and-forget drainOne after a write racing an awaited drainPending)
@@ -359,6 +382,22 @@ export async function drainOne(businessId, entityId) {
359
382
  catch { }
360
383
  return;
361
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
+ }
362
401
  // 3) Handle 409 RRR regen same UUID (POS create only, exact backend code)
363
402
  syncLogger.debug("SyncJobs", "check 409", { businessId, entityId, status: rest.status, code: rest.code });
364
403
  const is409 = rest.status === 409 && rest.code === "ORDER_NUMBER_TAKEN";
@@ -502,6 +541,11 @@ export async function pullRemoteOrders(businessId) {
502
541
  syncLogger.debug("SyncJobs", "pullRemoteOrders early-return gate", { businessId, onlineForRest, hasToken: isAuthenticated(), connectivity: getConnectivityState() });
503
542
  return;
504
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
+ }
505
549
  try {
506
550
  const http = getHttpClient();
507
551
  if (!http) {
@@ -513,6 +557,7 @@ export async function pullRemoteOrders(businessId) {
513
557
  let totalPages = 1;
514
558
  do {
515
559
  syncLogger.debug("SyncJobs", "pullRemoteOrders GET start", { businessId, page });
560
+ await paceSync();
516
561
  const res = await http.get(`/businesses/${businessId}/orders`, { params: { page, page_size: 20 } });
517
562
  const payload = res.data;
518
563
  const data = payload?.data ?? res.data ?? [];
@@ -549,7 +594,8 @@ export async function pullRemoteOrders(businessId) {
549
594
  // Also pull recent deliveries (single page recent) for single business
550
595
  try {
551
596
  const http = getHttpClient();
552
- if (http) {
597
+ if (http && !isRateLimited()) {
598
+ await paceSync();
553
599
  const r2 = await http.get(`/businesses/${businessId}/deliveries`, { params: { page: 1, page_size: 20 } });
554
600
  const d = r2.data?.data ?? [];
555
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.16",
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",