@chipmobilesdk/rn-payment 0.0.0-stage → 0.2.0
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/CHANGELOG.md +201 -0
- package/README.md +885 -3
- package/package.json +53 -5
- package/skills/sdk-rn-payment/SKILL.md +164 -0
- package/src/adapter/createStoreProvider.ts +117 -0
- package/src/adapter/iap-adapter.ts +547 -0
- package/src/adapter/port.ts +198 -0
- package/src/config/types.ts +295 -0
- package/src/config/validate.ts +232 -0
- package/src/core/mutex.ts +128 -0
- package/src/core/retry.ts +133 -0
- package/src/core/timers.ts +249 -0
- package/src/diagnostics/errors.ts +110 -0
- package/src/diagnostics/events.ts +266 -0
- package/src/diagnostics/messageKeys.ts +95 -0
- package/src/engine/engine.ts +951 -0
- package/src/engine/finalize.ts +298 -0
- package/src/engine/pending.ts +118 -0
- package/src/engine/products.ts +259 -0
- package/src/engine/purchase.ts +526 -0
- package/src/engine/subscriptions.ts +157 -0
- package/src/entitlements/aggregate.ts +242 -0
- package/src/entitlements/mapping.ts +67 -0
- package/src/entitlements/policy.ts +129 -0
- package/src/index.ts +172 -0
- package/src/persistence/mmkv-store.ts +150 -0
- package/src/persistence/snapshot.ts +247 -0
- package/src/react/PaymentProvider.tsx +79 -0
- package/src/react/hooks.ts +274 -0
- package/src/testing/fakeStore.ts +374 -0
- package/src/testing/index.ts +10 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog validation (FR-011).
|
|
3
|
+
*
|
|
4
|
+
* Runs at `configure`, before any connection is opened, so a bad catalog fails
|
|
5
|
+
* at configuration time rather than at the moment a user taps Buy. Every failure
|
|
6
|
+
* **names the offending entry** — "invalid catalog" alone is useless in an app
|
|
7
|
+
* with thirty products.
|
|
8
|
+
*
|
|
9
|
+
* All problems are collected rather than thrown one at a time, so a developer
|
|
10
|
+
* fixes the catalog in one pass instead of rerunning to find the next fault.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { PaymentConfigError } from '../diagnostics/errors';
|
|
14
|
+
import {
|
|
15
|
+
resolveForegroundSyncMinIntervalMs,
|
|
16
|
+
resolveProductCacheTtlMs,
|
|
17
|
+
resolveRetry,
|
|
18
|
+
resolveStaleAfterMs,
|
|
19
|
+
resolveTimeouts,
|
|
20
|
+
type ResolvedRetry,
|
|
21
|
+
type ResolvedTimeouts,
|
|
22
|
+
} from '../core/timers';
|
|
23
|
+
import type {
|
|
24
|
+
CatalogEntry,
|
|
25
|
+
EntitlementKey,
|
|
26
|
+
LapsePolicy,
|
|
27
|
+
PaymentConfig,
|
|
28
|
+
ProductKey,
|
|
29
|
+
ProductType,
|
|
30
|
+
ValidationUnavailablePolicy,
|
|
31
|
+
} from './types';
|
|
32
|
+
|
|
33
|
+
const PRODUCT_TYPES: readonly ProductType[] = [
|
|
34
|
+
'one_time_permanent',
|
|
35
|
+
'one_time_consumable',
|
|
36
|
+
'subscription',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** The platform whose store identifiers a catalog must supply. */
|
|
40
|
+
export type TargetPlatform = 'android' | 'ios';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A validated, indexed catalog plus every resolved policy value.
|
|
44
|
+
*
|
|
45
|
+
* Produced once so the hot paths never re-derive it, and so `getEntitlement` can
|
|
46
|
+
* stay a synchronous in-memory read (PERF-001).
|
|
47
|
+
*/
|
|
48
|
+
export interface ResolvedConfig {
|
|
49
|
+
readonly entries: ReadonlyMap<ProductKey, CatalogEntry>;
|
|
50
|
+
/** productKey → entitlement keys it grants. */
|
|
51
|
+
readonly grants: ReadonlyMap<ProductKey, readonly EntitlementKey[]>;
|
|
52
|
+
/** entitlementKey → product keys that can grant it (FR-051, FR-052). */
|
|
53
|
+
readonly sources: ReadonlyMap<EntitlementKey, readonly ProductKey[]>;
|
|
54
|
+
/** storeProductId → productKey, for translating store callbacks back (FR-003). */
|
|
55
|
+
readonly byStoreId: ReadonlyMap<string, ProductKey>;
|
|
56
|
+
readonly entitlementKeys: readonly EntitlementKey[];
|
|
57
|
+
readonly platform: TargetPlatform;
|
|
58
|
+
readonly enabled: boolean;
|
|
59
|
+
readonly gracePolicy: LapsePolicy;
|
|
60
|
+
readonly holdPolicy: LapsePolicy;
|
|
61
|
+
readonly staleAfterMs: number;
|
|
62
|
+
readonly productCacheTtlMs: number;
|
|
63
|
+
readonly foregroundSyncMinIntervalMs: number;
|
|
64
|
+
readonly timeouts: ResolvedTimeouts;
|
|
65
|
+
readonly retry: ResolvedRetry;
|
|
66
|
+
readonly onValidationUnavailable: ValidationUnavailablePolicy;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function validateConfig(
|
|
70
|
+
config: PaymentConfig,
|
|
71
|
+
platform: TargetPlatform = 'android',
|
|
72
|
+
): ResolvedConfig {
|
|
73
|
+
const problems: string[] = [];
|
|
74
|
+
const offenders: string[] = [];
|
|
75
|
+
|
|
76
|
+
const entries = new Map<ProductKey, CatalogEntry>();
|
|
77
|
+
const grants = new Map<ProductKey, readonly EntitlementKey[]>();
|
|
78
|
+
const sources = new Map<EntitlementKey, ProductKey[]>();
|
|
79
|
+
const byStoreId = new Map<string, ProductKey>();
|
|
80
|
+
|
|
81
|
+
if (config.catalog.length === 0) {
|
|
82
|
+
problems.push('catalog is empty');
|
|
83
|
+
offenders.push('catalog');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const entry of config.catalog) {
|
|
87
|
+
const key = entry.productKey;
|
|
88
|
+
|
|
89
|
+
if (!key || key.trim() === '') {
|
|
90
|
+
problems.push('a catalog entry has an empty productKey');
|
|
91
|
+
offenders.push('(empty productKey)');
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (entries.has(key)) {
|
|
96
|
+
problems.push(`duplicate productKey "${key}"`);
|
|
97
|
+
offenders.push(key);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!PRODUCT_TYPES.includes(entry.productType)) {
|
|
102
|
+
problems.push(
|
|
103
|
+
`productKey "${key}" has invalid productType "${String(entry.productType)}"`,
|
|
104
|
+
);
|
|
105
|
+
offenders.push(key);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const storeId = entry.storeProductIds[platform];
|
|
109
|
+
if (!storeId || storeId.trim() === '') {
|
|
110
|
+
problems.push(
|
|
111
|
+
`productKey "${key}" is missing a store product id for platform "${platform}"`,
|
|
112
|
+
);
|
|
113
|
+
offenders.push(key);
|
|
114
|
+
} else {
|
|
115
|
+
const existing = byStoreId.get(storeId);
|
|
116
|
+
if (existing !== undefined) {
|
|
117
|
+
// Two product keys pointing at one store id would make a store callback
|
|
118
|
+
// ambiguous, so the wrong entitlement could be granted.
|
|
119
|
+
problems.push(
|
|
120
|
+
`store product id "${storeId}" is used by both "${existing}" and "${key}"`,
|
|
121
|
+
);
|
|
122
|
+
offenders.push(key);
|
|
123
|
+
} else {
|
|
124
|
+
byStoreId.set(storeId, key);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!Array.isArray(entry.grants) || entry.grants.length === 0) {
|
|
129
|
+
problems.push(`productKey "${key}" grants no entitlement`);
|
|
130
|
+
offenders.push(key);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
entries.set(key, entry);
|
|
134
|
+
grants.set(key, [...(entry.grants ?? [])]);
|
|
135
|
+
|
|
136
|
+
for (const entitlement of entry.grants ?? []) {
|
|
137
|
+
const list = sources.get(entitlement);
|
|
138
|
+
if (list) {
|
|
139
|
+
list.push(key);
|
|
140
|
+
} else {
|
|
141
|
+
sources.set(entitlement, [key]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// An entitlement the app intends to query but no product can grant is almost
|
|
147
|
+
// always a typo, and it would otherwise sit at `not_entitled` forever with no
|
|
148
|
+
// indication why.
|
|
149
|
+
for (const entitlement of config.entitlements) {
|
|
150
|
+
if (!sources.has(entitlement)) {
|
|
151
|
+
problems.push(
|
|
152
|
+
`entitlement "${entitlement}" is declared but no catalog product grants it`,
|
|
153
|
+
);
|
|
154
|
+
offenders.push(entitlement);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A product granting something the app never declared is the mirror problem:
|
|
159
|
+
// harmless at runtime, but a strong signal of a rename half-applied.
|
|
160
|
+
const declared = new Set(config.entitlements);
|
|
161
|
+
for (const entitlement of sources.keys()) {
|
|
162
|
+
if (!declared.has(entitlement)) {
|
|
163
|
+
problems.push(
|
|
164
|
+
`entitlement "${entitlement}" is granted by a product but not declared in config.entitlements`,
|
|
165
|
+
);
|
|
166
|
+
offenders.push(entitlement);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (problems.length > 0) {
|
|
171
|
+
throw new PaymentConfigError({
|
|
172
|
+
code: 'PAYMENT_INVALID_CATALOG',
|
|
173
|
+
message: `Payment catalog is invalid: ${problems.join('; ')}`,
|
|
174
|
+
operation: 'configure',
|
|
175
|
+
offenders: [...new Set(offenders)],
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
entries,
|
|
181
|
+
grants,
|
|
182
|
+
sources: new Map([...sources].map(([k, v]) => [k, [...v]])),
|
|
183
|
+
byStoreId,
|
|
184
|
+
entitlementKeys: [...config.entitlements],
|
|
185
|
+
platform,
|
|
186
|
+
enabled: config.enabled ?? true,
|
|
187
|
+
gracePolicy: config.gracePolicy ?? 'grant',
|
|
188
|
+
holdPolicy: config.holdPolicy ?? 'withhold',
|
|
189
|
+
staleAfterMs: resolveStaleAfterMs(config.staleAfterMs),
|
|
190
|
+
productCacheTtlMs: resolveProductCacheTtlMs(config.productCacheTtlMs),
|
|
191
|
+
foregroundSyncMinIntervalMs: resolveForegroundSyncMinIntervalMs(
|
|
192
|
+
config.foregroundSyncMinIntervalMs,
|
|
193
|
+
),
|
|
194
|
+
timeouts: resolveTimeouts(config.timeouts),
|
|
195
|
+
retry: resolveRetry(config.retry),
|
|
196
|
+
onValidationUnavailable: config.onValidationUnavailable ?? 'grant_and_queue',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The fail-closed environment guard (FR-015).
|
|
202
|
+
*
|
|
203
|
+
* Separate from catalog validation because it is not about the catalog's shape
|
|
204
|
+
* but about whether this binary is allowed to move real money at all. Checked
|
|
205
|
+
* before connecting, and it refuses rather than warns: a non-production build
|
|
206
|
+
* that declares `production` is one tap away from charging a real card, and
|
|
207
|
+
* continuing silently is exactly the failure this package exists to prevent.
|
|
208
|
+
*/
|
|
209
|
+
export function assertEnvironmentSafe(config: PaymentConfig): void {
|
|
210
|
+
if (config.environment === 'production' && !config.isProductionBuild) {
|
|
211
|
+
throw new PaymentConfigError({
|
|
212
|
+
code: 'PAYMENT_PRODUCTION_CONFIG_IN_NON_PRODUCTION_BUILD',
|
|
213
|
+
message:
|
|
214
|
+
'environment is "production" but isProductionBuild is false. Refusing to ' +
|
|
215
|
+
'initialize: a non-production build must not be able to create a real ' +
|
|
216
|
+
'transaction.',
|
|
217
|
+
operation: 'configure',
|
|
218
|
+
offenders: ['environment'],
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Whether this configuration may hand a real purchase to a real store (FR-014).
|
|
225
|
+
*
|
|
226
|
+
* False outside production regardless of what else is configured. The engine
|
|
227
|
+
* records each refusal in observable status so a test can assert the block
|
|
228
|
+
* happened — the difference between a guarantee and a hope.
|
|
229
|
+
*/
|
|
230
|
+
export function mayCreateRealTransaction(config: PaymentConfig): boolean {
|
|
231
|
+
return config.environment === 'production' && config.isProductionBuild;
|
|
232
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concurrency primitives.
|
|
3
|
+
*
|
|
4
|
+
* These are what make three of the specification's guarantees structurally true
|
|
5
|
+
* rather than hoped for: concurrent initialization opens exactly one connection
|
|
6
|
+
* (FR-018), concurrent syncs make exactly one store request (FR-063), and a
|
|
7
|
+
* second purchase while one is open is refused rather than queued (FR-031).
|
|
8
|
+
*
|
|
9
|
+
* The distinction between the two primitives matters. `singleFlight` **joins**
|
|
10
|
+
* concurrent callers onto one operation — correct for init and sync, where every
|
|
11
|
+
* caller wants the same answer. `exclusive` **refuses** the second caller —
|
|
12
|
+
* correct for purchase, where joining would silently give two callers one
|
|
13
|
+
* transaction, and queueing would open a store surface the user no longer
|
|
14
|
+
* expects.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Joins concurrent callers onto one in-flight operation.
|
|
19
|
+
*
|
|
20
|
+
* Callers arriving during the flight receive the same promise. Once it settles
|
|
21
|
+
* the slot clears, so a later call starts fresh — this is de-duplication, not
|
|
22
|
+
* caching.
|
|
23
|
+
*/
|
|
24
|
+
export function createSingleFlight<T>(): {
|
|
25
|
+
run(operation: () => Promise<T>): Promise<T>;
|
|
26
|
+
inFlight(): boolean;
|
|
27
|
+
} {
|
|
28
|
+
let current: Promise<T> | undefined;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
run(operation: () => Promise<T>): Promise<T> {
|
|
32
|
+
if (current) {
|
|
33
|
+
return current;
|
|
34
|
+
}
|
|
35
|
+
const flight = operation().finally(() => {
|
|
36
|
+
if (current === flight) {
|
|
37
|
+
current = undefined;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
current = flight;
|
|
41
|
+
return flight;
|
|
42
|
+
},
|
|
43
|
+
inFlight: () => current !== undefined,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Distinguishes "refused because busy" from any other failure, without throwing. */
|
|
48
|
+
export type ExclusiveOutcome<T> =
|
|
49
|
+
| { acquired: true; value: T }
|
|
50
|
+
| { acquired: false };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Admits one operation at a time and refuses the rest.
|
|
54
|
+
*
|
|
55
|
+
* Refusal is returned, not thrown, because for a purchase it is a normal
|
|
56
|
+
* outcome the app reports to the user — not an error to be caught.
|
|
57
|
+
*/
|
|
58
|
+
export function createExclusive(): {
|
|
59
|
+
run<T>(operation: () => Promise<T>): Promise<ExclusiveOutcome<T>>;
|
|
60
|
+
busy(): boolean;
|
|
61
|
+
} {
|
|
62
|
+
let held = false;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
async run<T>(operation: () => Promise<T>): Promise<ExclusiveOutcome<T>> {
|
|
66
|
+
if (held) {
|
|
67
|
+
return { acquired: false };
|
|
68
|
+
}
|
|
69
|
+
held = true;
|
|
70
|
+
try {
|
|
71
|
+
return { acquired: true, value: await operation() };
|
|
72
|
+
} finally {
|
|
73
|
+
held = false;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
busy: () => held,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Rate limiter for automatic foreground sync (PERF-002).
|
|
82
|
+
*
|
|
83
|
+
* Rejects rather than defers: a skipped automatic sync costs nothing because the
|
|
84
|
+
* next foreground event will try again, whereas a queue of deferred syncs would
|
|
85
|
+
* fire a burst the moment the interval elapsed — the exact storm this prevents.
|
|
86
|
+
*/
|
|
87
|
+
export function createRateLimiter(
|
|
88
|
+
minIntervalMs: number,
|
|
89
|
+
now: () => number = Date.now,
|
|
90
|
+
): { tryAcquire(): boolean; reset(): void } {
|
|
91
|
+
let lastAt: number | undefined;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
tryAcquire(): boolean {
|
|
95
|
+
const at = now();
|
|
96
|
+
if (lastAt !== undefined && at - lastAt < minIntervalMs) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
lastAt = at;
|
|
100
|
+
return true;
|
|
101
|
+
},
|
|
102
|
+
reset(): void {
|
|
103
|
+
lastAt = undefined;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Rejects with a `PAYMENT_TIMEOUT`-shaped error after `ms`.
|
|
110
|
+
*
|
|
111
|
+
* Always clears its timer, including on the success path: a package that leaks a
|
|
112
|
+
* timer per operation fails the cleanup guarantee (FR-082, PERF-002).
|
|
113
|
+
*/
|
|
114
|
+
export function withTimeout<T>(
|
|
115
|
+
promise: Promise<T>,
|
|
116
|
+
ms: number,
|
|
117
|
+
onTimeout: () => Error,
|
|
118
|
+
): Promise<T> {
|
|
119
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
120
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
121
|
+
timer = setTimeout(() => reject(onTimeout()), ms);
|
|
122
|
+
});
|
|
123
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
124
|
+
if (timer !== undefined) {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
}
|
|
127
|
+
}) as Promise<T>;
|
|
128
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded backoff.
|
|
3
|
+
*
|
|
4
|
+
* "Bounded" is the whole point (FR-020, FR-045): an unbounded reconnect loop
|
|
5
|
+
* against a billing service that is down drains battery and hammers a system
|
|
6
|
+
* service, and an unbounded finalize loop hides a real failure behind noise.
|
|
7
|
+
*
|
|
8
|
+
* Delay computation is pure and separated from waiting so tests can assert the
|
|
9
|
+
* schedule without burning real time.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { BackoffConfig } from '../config/types';
|
|
13
|
+
|
|
14
|
+
/** Opaque timer handle: a Node Timeout and a browser numeric id are both valid. */
|
|
15
|
+
type TimerHandle = ReturnType<typeof setTimeout> | number;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Delay before `attempt` (1-based), capped and optionally jittered.
|
|
19
|
+
*
|
|
20
|
+
* Jitter is full jitter over [0, capped]: several devices that lost the billing
|
|
21
|
+
* connection at the same moment should not retry in lockstep.
|
|
22
|
+
*/
|
|
23
|
+
export function backoffDelayMs(
|
|
24
|
+
attempt: number,
|
|
25
|
+
config: Required<BackoffConfig>,
|
|
26
|
+
random: () => number = Math.random,
|
|
27
|
+
): number {
|
|
28
|
+
const raw = config.initialDelayMs * Math.pow(config.factor, attempt - 1);
|
|
29
|
+
const capped = Math.min(raw, config.maxDelayMs);
|
|
30
|
+
return config.jitter ? Math.floor(capped * random()) : capped;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The full schedule, for asserting a retry policy without waiting for it. */
|
|
34
|
+
export function backoffSchedule(config: Required<BackoffConfig>): number[] {
|
|
35
|
+
const delays: number[] = [];
|
|
36
|
+
for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) {
|
|
37
|
+
delays.push(backoffDelayMs(attempt, config, () => 1));
|
|
38
|
+
}
|
|
39
|
+
return delays;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RetryHandle {
|
|
43
|
+
/** Cancels any pending attempt. Idempotent, so cleanup can call it freely (FR-082). */
|
|
44
|
+
cancel(): void;
|
|
45
|
+
/** Attempts consumed so far, so a retry storm is observable rather than inferred. */
|
|
46
|
+
attempts(): number;
|
|
47
|
+
/** True once the per-session budget is spent. */
|
|
48
|
+
exhausted(): boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RetryRunnerOptions {
|
|
52
|
+
config: Required<BackoffConfig>;
|
|
53
|
+
/** Return true when the attempt succeeded and retrying should stop. */
|
|
54
|
+
attempt: (attemptNumber: number) => Promise<boolean>;
|
|
55
|
+
/** Called once the per-session budget is spent without success. */
|
|
56
|
+
onExhausted?: (attempts: number) => void;
|
|
57
|
+
/**
|
|
58
|
+
* Injected so a test can run the whole schedule synchronously instead of
|
|
59
|
+
* waiting minutes of real backoff. The handle is opaque, because a Node
|
|
60
|
+
* `Timeout` and a browser numeric id are both valid here.
|
|
61
|
+
*/
|
|
62
|
+
setTimeoutFn?: (fn: () => void, ms: number) => TimerHandle;
|
|
63
|
+
clearTimeoutFn?: (handle: TimerHandle) => void;
|
|
64
|
+
random?: () => number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Runs `attempt` under the bounded schedule.
|
|
69
|
+
*
|
|
70
|
+
* Exhaustion is reported rather than thrown: for finalization, a spent session
|
|
71
|
+
* budget is not a failure but a handoff to the next launch, and the caller is
|
|
72
|
+
* the only one that knows which of the two it is.
|
|
73
|
+
*/
|
|
74
|
+
export function runWithRetry(options: RetryRunnerOptions): RetryHandle {
|
|
75
|
+
const {
|
|
76
|
+
config,
|
|
77
|
+
attempt,
|
|
78
|
+
onExhausted,
|
|
79
|
+
setTimeoutFn = setTimeout,
|
|
80
|
+
clearTimeoutFn = clearTimeout,
|
|
81
|
+
random = Math.random,
|
|
82
|
+
} = options;
|
|
83
|
+
|
|
84
|
+
let attemptCount = 0;
|
|
85
|
+
let cancelled = false;
|
|
86
|
+
let pending: TimerHandle | undefined;
|
|
87
|
+
|
|
88
|
+
const schedule = (delayMs: number): void => {
|
|
89
|
+
pending = setTimeoutFn(() => {
|
|
90
|
+
pending = undefined;
|
|
91
|
+
run().catch(() => undefined);
|
|
92
|
+
}, delayMs);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const run = async (): Promise<void> => {
|
|
96
|
+
if (cancelled) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
attemptCount += 1;
|
|
100
|
+
let succeeded = false;
|
|
101
|
+
try {
|
|
102
|
+
succeeded = await attempt(attemptCount);
|
|
103
|
+
} catch {
|
|
104
|
+
// A throwing attempt is a failed attempt, nothing more. Letting it escape
|
|
105
|
+
// would abandon the schedule and, for finalization, silently drop a paid
|
|
106
|
+
// transaction.
|
|
107
|
+
succeeded = false;
|
|
108
|
+
}
|
|
109
|
+
if (cancelled || succeeded) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (attemptCount >= config.maxAttempts) {
|
|
113
|
+
onExhausted?.(attemptCount);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
schedule(backoffDelayMs(attemptCount + 1, config, random));
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Fire the first attempt immediately; the schedule owns everything after it.
|
|
120
|
+
run().catch(() => undefined);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
cancel(): void {
|
|
124
|
+
cancelled = true;
|
|
125
|
+
if (pending !== undefined) {
|
|
126
|
+
clearTimeoutFn(pending);
|
|
127
|
+
pending = undefined;
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
attempts: () => attemptCount,
|
|
131
|
+
exhausted: () => attemptCount >= config.maxAttempts,
|
|
132
|
+
};
|
|
133
|
+
}
|