@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 +1 -0
- package/dist/index.js +1 -0
- package/dist/providers/query-client.js +8 -0
- package/dist/reference/service.js +9 -0
- package/dist/sync/command-queue.service.js +32 -1
- 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 +50 -4
- package/package.json +1 -1
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 {
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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