@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.
- package/dist/adapters/sync-failure.d.ts +33 -0
- package/dist/adapters/sync-failure.js +23 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/providers/query-client.js +8 -0
- package/dist/reference/service.js +9 -0
- package/dist/sync/command-queue.service.d.ts +2 -0
- package/dist/sync/command-queue.service.js +80 -31
- package/dist/sync/engine.js +7 -0
- package/dist/sync/rate-limit.d.ts +42 -0
- package/dist/sync/rate-limit.js +104 -0
- package/dist/sync/sync-jobs.service.js +109 -52
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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 >=
|
|
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
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
|
|
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 >=
|
|
362
|
-
await this.failPermanently(db, command, message, `
|
|
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", "
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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,
|
package/dist/sync/engine.js
CHANGED
|
@@ -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
|
-
/**
|
|
26
|
-
const MAX_SYNC_JOB_ATTEMPTS =
|
|
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
|
-
|
|
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
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
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
|
-
|
|
488
|
+
// Re-pull reference so hours merge converges, then retry with backoff.
|
|
444
489
|
try {
|
|
445
|
-
const {
|
|
446
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
506
|
+
reportJobFailure(businessId, row, { status: rest.status, code: rest.code, message: rest.message, attempts, terminal: true });
|
|
507
|
+
clearRetry(businessId);
|
|
458
508
|
try {
|
|
459
|
-
const {
|
|
460
|
-
|
|
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
|
-
//
|
|
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