@vellumai/vellum-gateway 0.6.1 → 0.6.2
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/Dockerfile +2 -0
- package/package.json +1 -1
- package/src/__tests__/feature-flags-route.test.ts +38 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +93 -0
- package/src/feature-flag-registry.json +2 -2
- package/src/feature-flag-remote-store.ts +19 -0
- package/src/feature-flag-watcher.ts +38 -12
- package/src/remote-feature-flag-sync.ts +76 -18
package/Dockerfile
CHANGED
package/package.json
CHANGED
|
@@ -97,7 +97,7 @@ const {
|
|
|
97
97
|
} = await import("../feature-flag-defaults.js");
|
|
98
98
|
const { clearFeatureFlagStoreCache, readPersistedFeatureFlags } =
|
|
99
99
|
await import("../feature-flag-store.js");
|
|
100
|
-
const { clearRemoteFeatureFlagStoreCache } =
|
|
100
|
+
const { clearRemoteFeatureFlagStoreCache, writeRemoteFeatureFlags } =
|
|
101
101
|
await import("../feature-flag-remote-store.js");
|
|
102
102
|
|
|
103
103
|
describe("GET /v1/feature-flags handler", () => {
|
|
@@ -337,6 +337,43 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
337
337
|
expect(emailFlag.enabled).toBe(false);
|
|
338
338
|
});
|
|
339
339
|
|
|
340
|
+
test("reflects updated flags after remote sync writes new values (stale cache regression)", async () => {
|
|
341
|
+
// Scenario: the LD poller (RemoteFeatureFlagSync) writes
|
|
342
|
+
// email-channel: false, the gateway caches it, then a subsequent
|
|
343
|
+
// poll writes email-channel: true. The GET handler should return
|
|
344
|
+
// the updated value because writeRemoteFeatureFlags() updates
|
|
345
|
+
// both disk and the in-memory cache.
|
|
346
|
+
|
|
347
|
+
// Step 1: First poll writes email-channel: false (simulated via
|
|
348
|
+
// writeRemoteFeatureFlags, which is what the poller calls internally).
|
|
349
|
+
writeRemoteFeatureFlags({ "email-channel": false });
|
|
350
|
+
|
|
351
|
+
const handler = createFeatureFlagsGetHandler();
|
|
352
|
+
const res1 = await handler(
|
|
353
|
+
new Request("http://gateway.test/v1/feature-flags"),
|
|
354
|
+
);
|
|
355
|
+
const body1 = await res1.json();
|
|
356
|
+
const emailFlag1 = body1.flags.find(
|
|
357
|
+
(f: { key: string }) => f.key === "email-channel",
|
|
358
|
+
);
|
|
359
|
+
expect(emailFlag1.enabled).toBe(false);
|
|
360
|
+
|
|
361
|
+
// Step 2: Second poll writes email-channel: true — the poller
|
|
362
|
+
// calls writeRemoteFeatureFlags which updates file + cache.
|
|
363
|
+
writeRemoteFeatureFlags({ "email-channel": true });
|
|
364
|
+
|
|
365
|
+
// Step 3: The GET handler should immediately reflect the update
|
|
366
|
+
// without needing a file-watcher round-trip.
|
|
367
|
+
const res2 = await handler(
|
|
368
|
+
new Request("http://gateway.test/v1/feature-flags"),
|
|
369
|
+
);
|
|
370
|
+
const body2 = await res2.json();
|
|
371
|
+
const emailFlag2 = body2.flags.find(
|
|
372
|
+
(f: { key: string }) => f.key === "email-channel",
|
|
373
|
+
);
|
|
374
|
+
expect(emailFlag2.enabled).toBe(true);
|
|
375
|
+
});
|
|
376
|
+
|
|
340
377
|
test("registry default used when neither local nor remote is set", async () => {
|
|
341
378
|
// No local override
|
|
342
379
|
if (existsSync(featureFlagStorePath)) {
|
|
@@ -487,4 +487,97 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
487
487
|
const headers = init?.headers as Record<string, string>;
|
|
488
488
|
expect(headers.Authorization).toBe("Api-Key trimmed-key");
|
|
489
489
|
});
|
|
490
|
+
|
|
491
|
+
test("polls with backoff when initial fetch fails, then snaps to steady-state on success", async () => {
|
|
492
|
+
// Simulate: first two fetches fail (missing creds), third succeeds.
|
|
493
|
+
let callCount = 0;
|
|
494
|
+
const credsFn = async (key: string) => {
|
|
495
|
+
callCount++;
|
|
496
|
+
// First 6 calls = 2 attempts × 3 credential reads each → missing API key.
|
|
497
|
+
// After that, credentials are available.
|
|
498
|
+
if (callCount <= 6) {
|
|
499
|
+
if (key === "credential/vellum/assistant_api_key") return undefined;
|
|
500
|
+
return defaultCredentials()[key];
|
|
501
|
+
}
|
|
502
|
+
return defaultCredentials()[key];
|
|
503
|
+
};
|
|
504
|
+
const creds = { get: credsFn } as unknown as CredentialCache;
|
|
505
|
+
|
|
506
|
+
fetchMock = mock(async () =>
|
|
507
|
+
Response.json({ flags: { "backoff-flag": true } }),
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
const sync = new RemoteFeatureFlagSync({
|
|
511
|
+
credentials: creds,
|
|
512
|
+
initialPollIntervalMs: 50,
|
|
513
|
+
});
|
|
514
|
+
await sync.start();
|
|
515
|
+
|
|
516
|
+
// Initial fetch failed (missing creds) — no fetch calls yet
|
|
517
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
518
|
+
|
|
519
|
+
// Wait for first poll (50ms) — still fails (creds still missing)
|
|
520
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
521
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
522
|
+
|
|
523
|
+
// Wait for second poll (100ms = 50ms doubled) — creds now available
|
|
524
|
+
await new Promise((r) => setTimeout(r, 130));
|
|
525
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
526
|
+
|
|
527
|
+
clearRemoteFeatureFlagStoreCache();
|
|
528
|
+
expect(readRemoteFeatureFlags()).toEqual({ "backoff-flag": true });
|
|
529
|
+
|
|
530
|
+
sync.stop();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("snaps to steady-state interval immediately when initial fetch succeeds", async () => {
|
|
534
|
+
fetchMock = mock(async () => Response.json({ flags: { "ok-flag": true } }));
|
|
535
|
+
|
|
536
|
+
const sync = new RemoteFeatureFlagSync({
|
|
537
|
+
credentials: fakeCredentialCache(defaultCredentials()),
|
|
538
|
+
initialPollIntervalMs: 50,
|
|
539
|
+
});
|
|
540
|
+
await sync.start();
|
|
541
|
+
|
|
542
|
+
// Initial fetch succeeded — 1 call
|
|
543
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
544
|
+
|
|
545
|
+
// Wait past what would be the initial poll interval — should NOT poll
|
|
546
|
+
// again because the interval snapped to steady-state (5 min)
|
|
547
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
548
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
549
|
+
|
|
550
|
+
sync.stop();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("doubles poll interval on consecutive failures", async () => {
|
|
554
|
+
// Always fail — missing creds
|
|
555
|
+
const creds = defaultCredentials();
|
|
556
|
+
delete creds["credential/vellum/assistant_api_key"];
|
|
557
|
+
|
|
558
|
+
fetchMock = mock(async () => Response.json({ flags: {} }));
|
|
559
|
+
|
|
560
|
+
const sync = new RemoteFeatureFlagSync({
|
|
561
|
+
credentials: fakeCredentialCache(creds),
|
|
562
|
+
initialPollIntervalMs: 50,
|
|
563
|
+
});
|
|
564
|
+
await sync.start();
|
|
565
|
+
|
|
566
|
+
// No fetch calls (missing creds)
|
|
567
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
568
|
+
|
|
569
|
+
// After 50ms: first poll fires, still fails → interval doubles to 100ms
|
|
570
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
571
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
572
|
+
|
|
573
|
+
// After another 100ms: second poll fires, still fails → interval doubles to 200ms
|
|
574
|
+
await new Promise((r) => setTimeout(r, 130));
|
|
575
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
576
|
+
|
|
577
|
+
// After another 200ms: third poll fires
|
|
578
|
+
await new Promise((r) => setTimeout(r, 230));
|
|
579
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
580
|
+
|
|
581
|
+
sync.stop();
|
|
582
|
+
});
|
|
490
583
|
});
|
|
@@ -126,8 +126,8 @@
|
|
|
126
126
|
"scope": "macos",
|
|
127
127
|
"key": "referral-codes",
|
|
128
128
|
"label": "Referral Codes",
|
|
129
|
-
"description": "
|
|
130
|
-
"defaultEnabled":
|
|
129
|
+
"description": "Surface the Earn Credits referral entry points (sidebar drawer row and Billing tab button) that open the referral modal",
|
|
130
|
+
"defaultEnabled": true
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
"id": "managed-sign-in",
|
|
@@ -114,6 +114,25 @@ export function writeRemoteFeatureFlags(values: Record<string, boolean>): void {
|
|
|
114
114
|
log.info({ count: Object.keys(values).length }, "Wrote remote feature flags");
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Clear the in-memory cache so the next `readRemoteFeatureFlags()` call
|
|
119
|
+
* re-reads from disk. Useful in tests for resetting state between cases.
|
|
120
|
+
*/
|
|
117
121
|
export function clearRemoteFeatureFlagStoreCache(): void {
|
|
118
122
|
cachedRemoteValues = null;
|
|
119
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Re-read the remote feature flag file from disk into the in-memory cache.
|
|
127
|
+
*
|
|
128
|
+
* Called by the file watcher when `feature-flags-remote.json` changes on
|
|
129
|
+
* disk (e.g. written by a separate process or a previous gateway instance).
|
|
130
|
+
* This ensures the next `readRemoteFeatureFlags()` call returns fresh data
|
|
131
|
+
* without requiring every read to hit disk.
|
|
132
|
+
*/
|
|
133
|
+
export function refreshRemoteFeatureFlagStoreCache(): void {
|
|
134
|
+
cachedRemoteValues = null;
|
|
135
|
+
// Force a re-read into cache immediately so the next
|
|
136
|
+
// readRemoteFeatureFlags() call picks up the new values.
|
|
137
|
+
readRemoteFeatureFlags();
|
|
138
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Watches feature
|
|
3
|
-
* module-level
|
|
2
|
+
* Watches feature flag files for external modifications and invalidates /
|
|
3
|
+
* refreshes the corresponding module-level caches.
|
|
4
4
|
*
|
|
5
5
|
* Uses the same fs.watch() + debounce pattern as CredentialWatcher and
|
|
6
6
|
* ConfigFileWatcher. Watches the parent directory (not the file itself)
|
|
@@ -15,6 +15,10 @@ import {
|
|
|
15
15
|
clearFeatureFlagStoreCache,
|
|
16
16
|
getFeatureFlagStorePath,
|
|
17
17
|
} from "./feature-flag-store.js";
|
|
18
|
+
import {
|
|
19
|
+
refreshRemoteFeatureFlagStoreCache,
|
|
20
|
+
getRemoteFeatureFlagStorePath,
|
|
21
|
+
} from "./feature-flag-remote-store.js";
|
|
18
22
|
import { getLogger } from "./logger.js";
|
|
19
23
|
|
|
20
24
|
const log = getLogger("feature-flag-watcher");
|
|
@@ -24,16 +28,18 @@ const DEBOUNCE_MS = 500;
|
|
|
24
28
|
export class FeatureFlagWatcher {
|
|
25
29
|
private watcher: FSWatcher | null = null;
|
|
26
30
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
-
private
|
|
28
|
-
private
|
|
31
|
+
private localFlagFilename: string;
|
|
32
|
+
private remoteFlagFilename: string;
|
|
33
|
+
/** Accumulates which files changed during the debounce window. */
|
|
34
|
+
private pendingFilenames = new Set<string>();
|
|
29
35
|
|
|
30
36
|
constructor() {
|
|
31
|
-
this.
|
|
32
|
-
this.
|
|
37
|
+
this.localFlagFilename = basename(getFeatureFlagStorePath());
|
|
38
|
+
this.remoteFlagFilename = basename(getRemoteFeatureFlagStorePath());
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
start(): void {
|
|
36
|
-
const dir = dirname(
|
|
42
|
+
const dir = dirname(getFeatureFlagStorePath());
|
|
37
43
|
|
|
38
44
|
// Ensure the directory exists so fs.watch() doesn't throw ENOENT
|
|
39
45
|
// on a fresh instance where no flags have been persisted yet.
|
|
@@ -43,10 +49,14 @@ export class FeatureFlagWatcher {
|
|
|
43
49
|
|
|
44
50
|
try {
|
|
45
51
|
this.watcher = watch(dir, { persistent: false }, (_event, filename) => {
|
|
46
|
-
if (
|
|
52
|
+
if (
|
|
53
|
+
filename &&
|
|
54
|
+
filename !== this.localFlagFilename &&
|
|
55
|
+
filename !== this.remoteFlagFilename
|
|
56
|
+
) {
|
|
47
57
|
return;
|
|
48
58
|
}
|
|
49
|
-
this.scheduleInvalidation();
|
|
59
|
+
this.scheduleInvalidation(filename ?? undefined);
|
|
50
60
|
});
|
|
51
61
|
|
|
52
62
|
log.info({ path: dir }, "Watching for feature flag file changes");
|
|
@@ -66,14 +76,30 @@ export class FeatureFlagWatcher {
|
|
|
66
76
|
}
|
|
67
77
|
}
|
|
68
78
|
|
|
69
|
-
private scheduleInvalidation(): void {
|
|
79
|
+
private scheduleInvalidation(filename?: string): void {
|
|
80
|
+
if (filename) {
|
|
81
|
+
this.pendingFilenames.add(filename);
|
|
82
|
+
}
|
|
83
|
+
|
|
70
84
|
if (this.debounceTimer) {
|
|
71
85
|
clearTimeout(this.debounceTimer);
|
|
72
86
|
}
|
|
73
87
|
this.debounceTimer = setTimeout(() => {
|
|
74
88
|
this.debounceTimer = null;
|
|
75
|
-
|
|
76
|
-
|
|
89
|
+
|
|
90
|
+
const filenames = this.pendingFilenames;
|
|
91
|
+
this.pendingFilenames = new Set<string>();
|
|
92
|
+
|
|
93
|
+
if (filenames.has(this.localFlagFilename)) {
|
|
94
|
+
clearFeatureFlagStoreCache();
|
|
95
|
+
}
|
|
96
|
+
if (filenames.has(this.remoteFlagFilename)) {
|
|
97
|
+
refreshRemoteFeatureFlagStoreCache();
|
|
98
|
+
}
|
|
99
|
+
log.info(
|
|
100
|
+
{ filenames: [...filenames] },
|
|
101
|
+
"Feature flag cache invalidated due to file change",
|
|
102
|
+
);
|
|
77
103
|
}, DEBOUNCE_MS);
|
|
78
104
|
}
|
|
79
105
|
}
|
|
@@ -8,14 +8,21 @@ import { getLogger } from "./logger.js";
|
|
|
8
8
|
const log = getLogger("remote-feature-flag-sync");
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* Steady-state polling interval: 5 minutes.
|
|
12
12
|
*
|
|
13
13
|
* Configurable via `REMOTE_FF_POLL_INTERVAL_MS` env var for testing or
|
|
14
14
|
* deployment tuning.
|
|
15
15
|
*/
|
|
16
16
|
const DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000;
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Initial polling interval when the first fetch fails (e.g. CES sidecar
|
|
20
|
+
* not ready yet). Doubles on each consecutive failure until it reaches
|
|
21
|
+
* the steady-state interval.
|
|
22
|
+
*/
|
|
23
|
+
const INITIAL_POLL_INTERVAL_MS = 10_000;
|
|
24
|
+
|
|
25
|
+
function getMaxPollIntervalMs(): number {
|
|
19
26
|
const envVal = process.env.REMOTE_FF_POLL_INTERVAL_MS;
|
|
20
27
|
if (envVal) {
|
|
21
28
|
const parsed = parseInt(envVal, 10);
|
|
@@ -27,63 +34,114 @@ function getPollIntervalMs(): number {
|
|
|
27
34
|
export type RemoteFeatureFlagSyncConfig = {
|
|
28
35
|
/** Credential cache for resolving platform URL, API key, and assistant ID dynamically. */
|
|
29
36
|
credentials: CredentialCache;
|
|
37
|
+
/** Override the initial poll interval (ms) — useful for testing. Defaults to 10 000. */
|
|
38
|
+
initialPollIntervalMs?: number;
|
|
30
39
|
};
|
|
31
40
|
|
|
32
41
|
/**
|
|
33
42
|
* Manages the lifecycle of syncing remote feature flags from the platform.
|
|
34
43
|
*
|
|
35
44
|
* On start, fetches the current flag state and persists it to disk via the
|
|
36
|
-
* remote feature flag store, then polls
|
|
37
|
-
*
|
|
38
|
-
*
|
|
45
|
+
* remote feature flag store, then polls with adaptive back-off: starts at
|
|
46
|
+
* {@link INITIAL_POLL_INTERVAL_MS} and doubles on each failure until it
|
|
47
|
+
* reaches the steady-state interval. On the first success the interval
|
|
48
|
+
* snaps to steady-state immediately.
|
|
39
49
|
*/
|
|
40
50
|
export class RemoteFeatureFlagSync {
|
|
41
51
|
private started = false;
|
|
42
|
-
private pollTimer: ReturnType<typeof
|
|
52
|
+
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
53
|
+
private currentIntervalMs: number;
|
|
54
|
+
private readonly maxIntervalMs: number;
|
|
43
55
|
private readonly credentials: CredentialCache;
|
|
44
56
|
|
|
45
57
|
constructor(config: RemoteFeatureFlagSyncConfig) {
|
|
46
58
|
this.credentials = config.credentials;
|
|
59
|
+
this.currentIntervalMs =
|
|
60
|
+
config.initialPollIntervalMs ?? INITIAL_POLL_INTERVAL_MS;
|
|
61
|
+
this.maxIntervalMs = getMaxPollIntervalMs();
|
|
47
62
|
}
|
|
48
63
|
|
|
49
64
|
async start(): Promise<void> {
|
|
50
65
|
this.started = true;
|
|
51
66
|
|
|
67
|
+
let ok = false;
|
|
52
68
|
try {
|
|
53
|
-
await this.fetchAndCache();
|
|
69
|
+
ok = await this.fetchAndCache();
|
|
54
70
|
} catch (err) {
|
|
55
71
|
log.warn({ err }, "Failed to sync remote feature flags on startup");
|
|
56
72
|
}
|
|
57
73
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
this.
|
|
61
|
-
|
|
62
|
-
});
|
|
63
|
-
}, intervalMs);
|
|
74
|
+
if (ok) {
|
|
75
|
+
// First fetch succeeded — jump straight to steady-state polling.
|
|
76
|
+
this.currentIntervalMs = this.maxIntervalMs;
|
|
77
|
+
}
|
|
64
78
|
|
|
65
|
-
|
|
79
|
+
this.scheduleNextPoll();
|
|
80
|
+
log.info(
|
|
81
|
+
{ intervalMs: this.currentIntervalMs },
|
|
82
|
+
"Remote feature flag polling started",
|
|
83
|
+
);
|
|
66
84
|
}
|
|
67
85
|
|
|
68
86
|
stop(): void {
|
|
69
87
|
this.started = false;
|
|
70
88
|
if (this.pollTimer) {
|
|
71
|
-
|
|
89
|
+
clearTimeout(this.pollTimer);
|
|
72
90
|
this.pollTimer = null;
|
|
73
91
|
}
|
|
74
92
|
}
|
|
75
93
|
|
|
76
|
-
private
|
|
94
|
+
private scheduleNextPoll(): void {
|
|
95
|
+
this.pollTimer = setTimeout(() => {
|
|
96
|
+
this.poll();
|
|
97
|
+
}, this.currentIntervalMs);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private poll(): void {
|
|
101
|
+
if (!this.started) return;
|
|
102
|
+
this.fetchAndCache()
|
|
103
|
+
.then((ok) => {
|
|
104
|
+
if (ok) {
|
|
105
|
+
// Success — snap to steady-state interval.
|
|
106
|
+
this.currentIntervalMs = this.maxIntervalMs;
|
|
107
|
+
} else {
|
|
108
|
+
// Failure — double the interval, capped at max.
|
|
109
|
+
this.currentIntervalMs = Math.min(
|
|
110
|
+
this.currentIntervalMs * 2,
|
|
111
|
+
this.maxIntervalMs,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
.catch((err) => {
|
|
116
|
+
log.warn({ err }, "Failed to sync remote feature flags during poll");
|
|
117
|
+
this.currentIntervalMs = Math.min(
|
|
118
|
+
this.currentIntervalMs * 2,
|
|
119
|
+
this.maxIntervalMs,
|
|
120
|
+
);
|
|
121
|
+
})
|
|
122
|
+
.finally(() => {
|
|
123
|
+
if (this.started) {
|
|
124
|
+
this.scheduleNextPoll();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Fetch remote flags and write them to the store.
|
|
131
|
+
* Returns true if flags were successfully written, false otherwise.
|
|
132
|
+
*/
|
|
133
|
+
private async fetchAndCache(): Promise<boolean> {
|
|
77
134
|
const values = await this.fetchRemoteFeatureFlags();
|
|
78
135
|
if (values === null) {
|
|
79
|
-
log.
|
|
80
|
-
return;
|
|
136
|
+
log.warn("Skipping cache write — fetch returned no usable data");
|
|
137
|
+
return false;
|
|
81
138
|
}
|
|
82
139
|
writeRemoteFeatureFlags(values);
|
|
83
140
|
log.info(
|
|
84
141
|
{ count: Object.keys(values).length },
|
|
85
142
|
"Synced remote feature flags",
|
|
86
143
|
);
|
|
144
|
+
return true;
|
|
87
145
|
}
|
|
88
146
|
|
|
89
147
|
private async fetchRemoteFeatureFlags(): Promise<Record<
|