@jewel998/config 0.2.1 → 0.2.4

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.
@@ -1,78 +1,103 @@
1
- import type { CacheStorage, ConfigEventCallback, ConfigEventType } from "./types.js";
1
+ import { SdkError } from "./errors/index.js";
2
2
  import type { EvaluationContext } from "./plugins/types.js";
3
+ import type { CacheStorage } from "./types.js";
3
4
  export interface InitConfigOptions {
4
5
  /** Required. API key from the portal (cid_ for client, svr_ for server). */
5
6
  clientId: string;
6
7
  /**
7
- * Default/fallback values returned instantly before the API responds.
8
- * Keys are flag names, values are what your app uses immediately (optimistic).
8
+ * Tier 1 keys — fetched immediately, block ready() resolution.
9
+ * Use for values your app cannot render without.
10
+ * e.g. ["app.maintenance_mode", "feature.auth_provider"]
11
+ */
12
+ prefetch?: string[];
13
+ /**
14
+ * Default/fallback values returned instantly before any tier resolves.
15
+ * get() resolves immediately when a key has a default here.
9
16
  */
10
17
  defaults?: Record<string, unknown>;
11
- /** User context for targeting evaluation. Use autoContext() to auto-detect browser info. */
18
+ /** User context for targeting evaluation. Use autoContext() to auto-detect. */
12
19
  context?: EvaluationContext;
13
20
  /**
14
- * Your self-hosted API URL. Points to your Firebase deployment.
21
+ * Your self-hosted API URL.
15
22
  * Default: https://jewel998-config.web.app/api (demo instance)
16
- *
17
- * @example "https://your-project.web.app/api"
18
23
  */
19
24
  baseUrl?: string;
25
+ /** Cache storage adapter. Default: memoryStorage() */
26
+ storage?: CacheStorage;
20
27
  /**
21
- * Cache storage adapter. Controls where resolved config values are persisted.
22
- *
23
- * - `memoryStorage()` (default) — In-memory, lost on page reload
24
- * - `browserStorage()` — localStorage, persists across page reloads and sessions
25
- *
26
- * @example
27
- * ```ts
28
- * import { browserStorage } from "@jewel998/config";
29
- * initConfig({ storage: browserStorage({ prefix: "myapp" }) })
30
- * ```
28
+ * Version polling interval in milliseconds.
29
+ * Default: 300_000 (5 minutes). Set to 0 to disable.
31
30
  */
32
- storage?: CacheStorage;
31
+ pollInterval?: number;
32
+ /**
33
+ * Global timeout in milliseconds for get() calls with no default.
34
+ * If a key does not arrive within this window, get() rejects with
35
+ * SdkError { type: "TIMEOUT" } and onError is called.
36
+ * Default: 30_000 (30 seconds).
37
+ */
38
+ timeout?: number;
33
39
  /**
34
- * Polling interval in milliseconds for version checks.
35
- * The SDK periodically calls /api/version (lightweight ~100 bytes)
36
- * and only re-fetches full data when the version changes.
40
+ * Global error handler. Called on every SDK-level error:
41
+ * timeouts, fetch failures, auth errors, key-not-found, rate limits.
37
42
  *
38
- * Default: 300000 (5 minutes). Set to 0 to disable polling.
43
+ * get() also rejects with the same error — onError is for centralised
44
+ * logging/monitoring, not a replacement for catch().
39
45
  */
40
- pollInterval?: number;
46
+ onError?: (error: SdkError) => void;
41
47
  }
42
48
  export interface Flags {
43
- /** Get a flag value. Returns the default until the API responds, then the resolved value. */
44
- get<T = unknown>(key: string): T;
45
- /** Get a boolean flag. Returns false if the flag doesn't exist. */
46
- flag(key: string): boolean;
47
- /** Get all resolved flag values. */
48
- all(): Record<string, unknown>;
49
- /** Update user context. Triggers a re-fetch in client-key mode. */
49
+ /**
50
+ * Resolves when Tier 1 (init prefetch) keys have been fetched.
51
+ * Resolves instantly if no prefetch keys were declared.
52
+ */
53
+ ready(): Promise<void>;
54
+ /**
55
+ * Tier 2 fetch hint — fire-and-forget.
56
+ * Keys already tracked in Tier 1 or already fetched are skipped.
57
+ * Emits "updated" and "updated:key" events per key on completion.
58
+ */
59
+ prefetch(keys: string[]): void;
60
+ /**
61
+ * Get a typed config value asynchronously.
62
+ *
63
+ * Resolution order:
64
+ * 1. Memory / cache hit → resolves instantly
65
+ * 2. Default in defaults map → resolves instantly
66
+ * 3. Inline defaultValue arg → resolves instantly
67
+ * 4. No default → suspends until idle fetch or refresh
68
+ * delivers the key
69
+ *
70
+ * On timeout (global, default 30s):
71
+ * - onError is called with SdkError { type: "TIMEOUT", key }
72
+ * - Promise rejects with the same SdkError
73
+ */
74
+ get<T = unknown>(key: string): Promise<T>;
75
+ get<T = unknown>(key: string, defaultValue: T): Promise<T>;
76
+ /**
77
+ * Returns a snapshot of all currently fetched values merged with defaults.
78
+ * Does not wait for any in-flight fetches — returns current state only.
79
+ */
80
+ all(): Promise<Record<string, unknown>>;
81
+ /**
82
+ * Update user context. Fire-and-forget, debounced 100ms.
83
+ * Re-fetches all already-fetched keys in tier order with the new context.
84
+ * Subscribe to "updated" events to react when the re-fetch completes.
85
+ */
50
86
  setContext(context: EvaluationContext): void;
51
- /** Force re-fetch from the API. */
87
+ /**
88
+ * Re-fetch all already-fetched keys in tier order.
89
+ * Resolves when all re-fetches complete.
90
+ */
52
91
  refresh(): Promise<void>;
53
- /** Subscribe to events: "ready", "updated", "fetchError" */
54
- on<E extends ConfigEventType>(event: E, cb: ConfigEventCallback<E>): void;
92
+ /**
93
+ * Subscribe to events.
94
+ *
95
+ * Key-specific: flags.on("updated:feature.dark_mode", (value) => ...)
96
+ * Batch updated: flags.on("updated", ({ keys, source }) => ...)
97
+ * Fetch errors: flags.on("fetchError", ({ error }) => ...)
98
+ */
99
+ on(event: string, cb: (payload: unknown) => void): void;
55
100
  /** Unsubscribe from events. */
56
- off<E extends ConfigEventType>(event: E, cb: ConfigEventCallback<E>): void;
101
+ off(event: string, cb: (payload: unknown) => void): void;
57
102
  }
58
- /**
59
- * Initialize the feature flags SDK.
60
- *
61
- * Returns a Flags instance that immediately serves default values,
62
- * then resolves to real values from the API in the background.
63
- *
64
- * @example
65
- * ```ts
66
- * const flags = initConfig({
67
- * clientId: "cid_xxx",
68
- * defaults: { "feature.dark_mode": false },
69
- * context: autoContext({ userId: "user_123", plan: "pro" }),
70
- * });
71
- *
72
- * flags.get("feature.dark_mode"); // → false (instant)
73
- * flags.on("updated", () => {
74
- * flags.get("feature.dark_mode"); // → true (from API)
75
- * });
76
- * ```
77
- */
78
103
  export declare function initConfig(options: InitConfigOptions): Flags;
@@ -1,129 +1,302 @@
1
1
  // ═══════════════════════════════════════════════════════════════
2
2
  // initConfig — Primary SDK entry point
3
3
  //
4
- // Usage:
5
- // const flags = initConfig({
6
- // clientId: "cid_xxx",
7
- // defaults: { "feature.dark_mode": false, "app.upload_limit": 50 },
8
- // context: autoContext({ userId: "user_123", plan: "pro" }),
9
- // });
4
+ // Three-tier priority fetching model:
10
5
  //
11
- // flags.get("feature.dark_mode") // → false (instant, from defaults)
12
- // // ...API responds...
13
- // flags.get("feature.dark_mode") // → true (resolved from server)
6
+ // Tier 1 — PREFETCH (init)
7
+ // Declared via `prefetch: [...]` at initConfig time.
8
+ // Fetched immediately. ready() blocks until this completes.
9
+ //
10
+ // Tier 2 — PAGE (runtime)
11
+ // Declared via flags.prefetch(keys) per route/component.
12
+ // Fire-and-forget. Keys deduplicated against Tier 1.
13
+ //
14
+ // Tier 3 — IDLE
15
+ // Full fetchAll() during browser idle time via requestIdleCallback.
16
+ // Fills in all remaining keys in the background.
17
+ //
18
+ // get() behaviour:
19
+ // - Memory/cache hit → resolves instantly
20
+ // - Default provided → resolves instantly
21
+ // - No default → suspends until idle fetch or refresh delivers
22
+ // the key, or global timeout fires
23
+ // - Timeout → onError called, Promise rejects with SdkError
24
+ //
25
+ // Context update:
26
+ // setContext() is fire-and-forget (debounced 100ms).
27
+ // Re-fetches only already-fetched keys in tier order.
14
28
  // ═══════════════════════════════════════════════════════════════
15
- import { createConfig } from "./createConfig.js";
16
- /**
17
- * Initialize the feature flags SDK.
18
- *
19
- * Returns a Flags instance that immediately serves default values,
20
- * then resolves to real values from the API in the background.
21
- *
22
- * @example
23
- * ```ts
24
- * const flags = initConfig({
25
- * clientId: "cid_xxx",
26
- * defaults: { "feature.dark_mode": false },
27
- * context: autoContext({ userId: "user_123", plan: "pro" }),
28
- * });
29
- *
30
- * flags.get("feature.dark_mode"); // → false (instant)
31
- * flags.on("updated", () => {
32
- * flags.get("feature.dark_mode"); // → true (from API)
33
- * });
34
- * ```
35
- */
29
+ import { memoryStorage } from "./cache/memoryStorage.js";
30
+ import { SdkError, ConfigError } from "./errors/index.js";
31
+ import { TypedEventEmitter } from "./events/EventEmitter.js";
32
+ import { createProjectedFetcher } from "./fetch/projectedFetcher.js";
33
+ import { TierFetcher } from "./fetch/TierFetcher.js";
34
+ import { withRetry } from "./retry/RetryEngine.js";
35
+ import { createHttpTransport } from "./transport/HttpTransport.js";
36
+ import { DEFAULT_CACHE_TTL, DEFAULT_RETRY } from "./types.js";
37
+ const DEFAULT_BASE_URL = "https://jewel998-config.web.app/api";
38
+ const DEFAULT_TIMEOUT_MS = 30_000;
39
+ const CONTEXT_DEBOUNCE_MS = 100;
40
+ const MIN_REFETCH_INTERVAL_MS = 30_000;
41
+ // ─── Implementation ────────────────────────────────────────────
36
42
  export function initConfig(options) {
43
+ if (!options.clientId) {
44
+ throw new ConfigError("clientId is required", "MISSING_CLIENT_ID");
45
+ }
46
+ if (typeof window === "undefined" && !options.clientId.startsWith("svr_")) {
47
+ throw new ConfigError("Client keys (cid_) require a browser environment.", "INITIALIZATION_FAILED");
48
+ }
37
49
  const defaults = options.defaults ?? {};
38
- const pollInterval = options.pollInterval ?? 300_000; // 5 minutes default
39
- const baseUrl = options.baseUrl ?? "https://jewel998-config.web.app/api";
40
- // Create the underlying config client (optimistic strategy = instant return + background fetch)
41
- const client = createConfig({
42
- clientId: options.clientId,
43
- loadingStrategy: "optimistic",
44
- context: options.context,
45
- storage: options.storage,
50
+ const pollInterval = options.pollInterval ?? 300_000;
51
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
52
+ const storage = options.storage ?? memoryStorage();
53
+ const retry = { ...DEFAULT_RETRY };
54
+ const timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;
55
+ const onError = options.onError;
56
+ const initPrefetch = options.prefetch ?? [];
57
+ const events = new TypedEventEmitter();
58
+ let currentContext = options.context ?? {};
59
+ // ── Transport + fetcher ──────────────────────────────────────
60
+ const transport = createHttpTransport({
46
61
  baseUrl,
62
+ clientId: options.clientId,
63
+ evaluationMode: "server",
64
+ getContext: () => currentContext,
65
+ });
66
+ const baseFetcher = createProjectedFetcher(transport);
67
+ const tierFetcher = new TierFetcher(baseFetcher);
68
+ // ── In-memory data store ─────────────────────────────────────
69
+ const data = {};
70
+ function applyResult(result) {
71
+ const keys = Object.keys(result);
72
+ for (const key of keys) {
73
+ data[key] = result[key];
74
+ storage.set(key, result[key], DEFAULT_CACHE_TTL);
75
+ }
76
+ // Update the __all__ snapshot incrementally
77
+ const existing = storage.get("__all__") ?? {};
78
+ storage.set("__all__", { ...existing, ...result }, DEFAULT_CACHE_TTL);
79
+ return keys;
80
+ }
81
+ function emitUpdated(keys, source) {
82
+ if (keys.length === 0)
83
+ return;
84
+ events.emit("updated", { keys, source });
85
+ }
86
+ // ── Tier 1: init prefetch ────────────────────────────────────
87
+ const readyPromise = initPrefetch.length > 0
88
+ ? (async () => {
89
+ try {
90
+ const result = await withRetry(() => tierFetcher.fetchTier(initPrefetch, "prefetch"), retry);
91
+ const fetched = applyResult(result);
92
+ tierFetcher.notifyWaiters(result);
93
+ emitUpdated(fetched, "prefetch");
94
+ }
95
+ catch (err) {
96
+ const sdkErr = new SdkError("FETCH_FAILED", `Prefetch failed: ${err.message}`, undefined, err);
97
+ onError?.(sdkErr);
98
+ events.emit("fetchError", {
99
+ error: sdkErr,
100
+ retryCount: retry.maxRetries,
101
+ willRetry: false,
102
+ });
103
+ // Don't re-throw — app still runs on defaults
104
+ }
105
+ })()
106
+ : Promise.resolve();
107
+ // ── Tier 3: idle fetch ───────────────────────────────────────
108
+ readyPromise.then(() => {
109
+ scheduleIdleFetch(async () => {
110
+ try {
111
+ const result = await tierFetcher.fetchAll();
112
+ const fetched = applyResult(result);
113
+ tierFetcher.notifyWaiters(result);
114
+ emitUpdated(fetched, "idle");
115
+ }
116
+ catch {
117
+ // Idle fetch is best-effort — silently ignore
118
+ // Pending waiters will eventually time out via get()
119
+ }
120
+ });
47
121
  });
48
- // Version-based polling: check /api/version periodically, delta-fetch on change
122
+ // ── Version polling ──────────────────────────────────────────
49
123
  let cachedVersion = null;
50
- let pollTimer = null;
51
124
  async function checkVersion() {
52
125
  try {
53
- const res = await fetch(`${baseUrl}/getVersion?clientId=${options.clientId}`, {
54
- headers: cachedVersion
55
- ? { "If-None-Match": `"${cachedVersion}"` }
56
- : {},
126
+ const res = await fetch(`${baseUrl}/v1/version?clientId=${options.clientId}`, {
127
+ headers: cachedVersion ? { "If-None-Match": `"${cachedVersion}"` } : {},
57
128
  });
58
- // 304 = version unchanged, no action needed
59
129
  if (res.status === 304)
60
130
  return;
61
131
  if (!res.ok)
62
132
  return;
63
- const { version, changedKeys } = (await res.json());
64
- // First check — just store version, no refetch (initial fetch already happened)
133
+ const { version } = (await res.json());
65
134
  if (cachedVersion === null) {
66
135
  cachedVersion = version;
67
136
  return;
68
137
  }
69
- // Version changed — need to refresh
70
138
  if (version !== cachedVersion) {
71
139
  cachedVersion = version;
72
- // If we know which keys changed, we could delta-fetch in the future
73
- // For now, full refresh (still efficient with CDN + deduplication)
74
- await client.refresh();
140
+ await doRefresh();
75
141
  }
76
142
  }
77
143
  catch {
78
- // Silently ignore polling errors — cache continues to work
144
+ // Silently ignore polling errors
79
145
  }
80
146
  }
81
- // Start polling if interval > 0
82
147
  if (pollInterval > 0 && typeof window !== "undefined") {
83
- // Initial version check after first fetch completes
84
- client.on("ready", () => {
85
- void checkVersion();
86
- });
87
- pollTimer = setInterval(checkVersion, pollInterval);
88
- // Also check on tab visibility change (user returns to tab)
148
+ readyPromise.then(() => void checkVersion());
149
+ setInterval(checkVersion, pollInterval);
89
150
  document.addEventListener("visibilitychange", () => {
90
- if (document.visibilityState === "visible") {
151
+ if (document.visibilityState === "visible")
91
152
  void checkVersion();
92
- }
93
153
  });
94
154
  }
95
- return {
96
- get(key) {
97
- const resolved = client.getValue(key);
98
- if (resolved !== undefined)
99
- return resolved;
100
- return defaults[key];
155
+ // ── Refresh (re-fetch fetched keys in tier order) ─────────────
156
+ let lastRefetchTime = 0;
157
+ async function doRefresh() {
158
+ if (Date.now() - lastRefetchTime < MIN_REFETCH_INTERVAL_MS)
159
+ return;
160
+ lastRefetchTime = Date.now();
161
+ const { prefetch: pf, page, idle } = tierFetcher.getFetchedKeys();
162
+ const allFetched = [];
163
+ const refetches = [];
164
+ if (pf.length > 0) {
165
+ refetches.push(withRetry(() => tierFetcher.refetchKeys(pf), retry)
166
+ .then((r) => {
167
+ applyResult(r);
168
+ tierFetcher.notifyWaiters(r);
169
+ allFetched.push(...Object.keys(r));
170
+ })
171
+ .catch(() => { }));
172
+ }
173
+ if (page.length > 0) {
174
+ refetches.push(withRetry(() => tierFetcher.refetchKeys(page), retry)
175
+ .then((r) => {
176
+ applyResult(r);
177
+ tierFetcher.notifyWaiters(r);
178
+ allFetched.push(...Object.keys(r));
179
+ })
180
+ .catch(() => { }));
181
+ }
182
+ if (idle.length > 0) {
183
+ refetches.push(tierFetcher
184
+ .fetchAll()
185
+ .then((r) => {
186
+ applyResult(r);
187
+ tierFetcher.notifyWaiters(r);
188
+ allFetched.push(...Object.keys(r));
189
+ })
190
+ .catch(() => { }));
191
+ }
192
+ await Promise.allSettled(refetches);
193
+ emitUpdated(allFetched, "refresh");
194
+ }
195
+ // ── Context re-fetch ─────────────────────────────────────────
196
+ let contextDebounce = null;
197
+ // ── Public API ───────────────────────────────────────────────
198
+ const flags = {
199
+ ready() {
200
+ return readyPromise;
101
201
  },
102
- flag(key) {
103
- const resolved = client.getFlag(key);
104
- if (resolved)
105
- return true;
106
- const val = client.getValue(key);
107
- if (val !== undefined)
108
- return val === true;
109
- return defaults[key] === true;
202
+ prefetch(keys) {
203
+ void (async () => {
204
+ try {
205
+ const result = await withRetry(() => tierFetcher.fetchTier(keys, "page"), retry);
206
+ const fetched = applyResult(result);
207
+ tierFetcher.notifyWaiters(result);
208
+ emitUpdated(fetched, "page");
209
+ }
210
+ catch (err) {
211
+ const sdkErr = new SdkError("FETCH_FAILED", `Page prefetch failed: ${err.message}`, undefined, err);
212
+ onError?.(sdkErr);
213
+ events.emit("fetchError", {
214
+ error: sdkErr,
215
+ retryCount: retry.maxRetries,
216
+ willRetry: false,
217
+ });
218
+ }
219
+ })();
220
+ },
221
+ get(key, defaultValue) {
222
+ // 1. Memory hit
223
+ if (key in data)
224
+ return Promise.resolve(data[key]);
225
+ // 2. Cache hit
226
+ const cached = storage.get(key);
227
+ if (cached !== undefined) {
228
+ data[key] = cached;
229
+ return Promise.resolve(cached);
230
+ }
231
+ // 3. Default from map
232
+ if (key in defaults)
233
+ return Promise.resolve(defaults[key]);
234
+ // 4. Inline default
235
+ if (defaultValue !== undefined)
236
+ return Promise.resolve(defaultValue);
237
+ // 5. Suspend — wait for idle fetch or refresh to deliver this key
238
+ return new Promise((resolve, reject) => {
239
+ const timer = setTimeout(() => {
240
+ const err = new SdkError("TIMEOUT", `get("${key}") timed out after ${timeoutMs}ms — key was not delivered by idle fetch or refresh`, key);
241
+ onError?.(err);
242
+ reject(err);
243
+ }, timeoutMs);
244
+ tierFetcher.waitForKey(key).then((value) => {
245
+ clearTimeout(timer);
246
+ resolve(value);
247
+ }, (err) => {
248
+ clearTimeout(timer);
249
+ const sdkErr = new SdkError("FETCH_FAILED", err.message, key, err);
250
+ onError?.(sdkErr);
251
+ reject(sdkErr);
252
+ });
253
+ });
110
254
  },
111
- all() {
112
- const resolved = client.getAll();
113
- return { ...defaults, ...resolved };
255
+ async all() {
256
+ const snapshot = storage.get("__all__") ?? {};
257
+ return { ...defaults, ...snapshot, ...data };
114
258
  },
115
259
  setContext(context) {
116
- client.setContext(context);
260
+ currentContext = { ...context };
261
+ if (contextDebounce)
262
+ clearTimeout(contextDebounce);
263
+ contextDebounce = setTimeout(() => {
264
+ contextDebounce = null;
265
+ void doRefresh();
266
+ }, CONTEXT_DEBOUNCE_MS);
117
267
  },
118
- refresh() {
119
- return client.refresh();
268
+ async refresh() {
269
+ await doRefresh();
120
270
  },
121
271
  on(event, cb) {
122
- client.on(event, cb);
272
+ // Key-specific: "updated:feature.dark_mode"
273
+ if (event.startsWith("updated:")) {
274
+ const key = event.slice("updated:".length);
275
+ events.onKey(key, cb);
276
+ return;
277
+ }
278
+ events.on(event, cb);
123
279
  },
124
280
  off(event, cb) {
125
- client.off(event, cb);
281
+ if (event.startsWith("updated:")) {
282
+ const key = event.slice("updated:".length);
283
+ events.offKey(key, cb);
284
+ return;
285
+ }
286
+ events.off(event, cb);
126
287
  },
127
288
  };
289
+ return flags;
290
+ }
291
+ // ─── Idle scheduling ──────────────────────────────────────────
292
+ function scheduleIdleFetch(fn) {
293
+ if (typeof window === "undefined")
294
+ return;
295
+ if ("requestIdleCallback" in window) {
296
+ window.requestIdleCallback(fn, { timeout: 2000 });
297
+ }
298
+ else {
299
+ setTimeout(fn, 200);
300
+ }
128
301
  }
129
302
  //# sourceMappingURL=initConfig.js.map
@@ -1,3 +1,3 @@
1
1
  export { archivedPlugin } from "./archivedPlugin.js";
2
- export { validateStateTransition, VALID_TRANSITIONS, } from "./stateTransitions.js";
2
+ export { validateStateTransition, VALID_TRANSITIONS } from "./stateTransitions.js";
3
3
  export type { LifecycleState } from "./stateTransitions.js";
@@ -2,5 +2,5 @@
2
2
  // Lifecycle Plugin — Public Barrel Export
3
3
  // ═══════════════════════════════════════════════════════════════
4
4
  export { archivedPlugin } from "./archivedPlugin.js";
5
- export { validateStateTransition, VALID_TRANSITIONS, } from "./stateTransitions.js";
5
+ export { validateStateTransition, VALID_TRANSITIONS } from "./stateTransitions.js";
6
6
  //# sourceMappingURL=index.js.map
@@ -16,11 +16,7 @@ export function murmurhash3_32(key, seed = 0) {
16
16
  const c2 = 0x1b873593;
17
17
  let i = 0;
18
18
  while (i + 4 <= len) {
19
- let k1 = (bytes[i] |
20
- (bytes[i + 1] << 8) |
21
- (bytes[i + 2] << 16) |
22
- (bytes[i + 3] << 24)) >>>
23
- 0;
19
+ let k1 = (bytes[i] | (bytes[i + 1] << 8) | (bytes[i + 2] << 16) | (bytes[i + 3] << 24)) >>> 0;
24
20
  k1 = Math.imul(k1, c1) >>> 0;
25
21
  k1 = ((k1 << 15) | (k1 >>> 17)) >>> 0;
26
22
  k1 = Math.imul(k1, c2) >>> 0;
@@ -1,4 +1,4 @@
1
- import { AuthenticationError, ConfigError } from "../errors/index.js";
1
+ import { AuthenticationError, ConfigError, RateLimitError } from "../errors/index.js";
2
2
  import { DEFAULT_RETRY } from "../types.js";
3
3
  export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
4
  export const isNonRetryableError = (error) => {
@@ -20,6 +20,20 @@ export const isNonRetryableError = (error) => {
20
20
  }
21
21
  return false;
22
22
  };
23
+ /**
24
+ * Get the retry delay for an error. If the error is a RateLimitError with
25
+ * a server-specified Retry-After value, use that instead of calculated backoff.
26
+ */
27
+ const getRetryDelay = (error, attempt, config) => {
28
+ // Respect server-specified Retry-After for rate limit errors
29
+ if (error instanceof RateLimitError && error.retryAfterSeconds != null) {
30
+ return error.retryAfterSeconds * 1000;
31
+ }
32
+ // Default: exponential backoff with jitter
33
+ const delay = Math.min(config.baseDelay * Math.pow(config.multiplier, attempt), config.maxDelay);
34
+ // Add jitter (±25%) to prevent thundering herd
35
+ return delay * (0.75 + Math.random() * 0.5);
36
+ };
23
37
  export const withRetry = async (fn, options, onRetry) => {
24
38
  const config = { ...DEFAULT_RETRY, ...options };
25
39
  let lastError;
@@ -33,11 +47,9 @@ export const withRetry = async (fn, options, onRetry) => {
33
47
  throw error;
34
48
  }
35
49
  if (attempt < config.maxRetries) {
36
- const delay = Math.min(config.baseDelay * Math.pow(config.multiplier, attempt), config.maxDelay);
37
- // Add jitter (±25%) to prevent thundering herd
38
- const jitter = delay * (0.75 + Math.random() * 0.5);
39
- onRetry?.(attempt + 1, lastError, jitter);
40
- await sleep(jitter);
50
+ const delay = getRetryDelay(lastError, attempt, config);
51
+ onRetry?.(attempt + 1, lastError, delay);
52
+ await sleep(delay);
41
53
  }
42
54
  }
43
55
  }