@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 CHANGED
@@ -20,6 +20,8 @@ WORKDIR /app
20
20
 
21
21
  RUN apt-get update && apt-get upgrade -y && apt-get install -y \
22
22
  ca-certificates \
23
+ iproute2 \
24
+ procps \
23
25
  && rm -rf /var/lib/apt/lists/*
24
26
 
25
27
  # Copy bun binary from builder
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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": "Show the referral invite link and stats panel on the Billing tab in Settings",
130
- "defaultEnabled": false
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-flags.json for external modifications and invalidates the
3
- * module-level cache in feature-flag-store.ts.
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 flagPath: string;
28
- private flagFilename: string;
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.flagPath = getFeatureFlagStorePath();
32
- this.flagFilename = basename(this.flagPath);
37
+ this.localFlagFilename = basename(getFeatureFlagStorePath());
38
+ this.remoteFlagFilename = basename(getRemoteFeatureFlagStorePath());
33
39
  }
34
40
 
35
41
  start(): void {
36
- const dir = dirname(this.flagPath);
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 (filename && filename !== this.flagFilename) {
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
- clearFeatureFlagStoreCache();
76
- log.info("Feature flag cache invalidated due to file change");
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
- * Default polling interval: 5 minutes.
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
- function getPollIntervalMs(): number {
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 on a configurable interval. Errors
37
- * are caught and logged the system falls through to registry defaults if
38
- * this fails.
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 setInterval> | null = null;
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
- const intervalMs = getPollIntervalMs();
59
- this.pollTimer = setInterval(() => {
60
- this.fetchAndCache().catch((err) => {
61
- log.warn({ err }, "Failed to sync remote feature flags during poll");
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
- log.info({ intervalMs }, "Remote feature flag polling started");
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
- clearInterval(this.pollTimer);
89
+ clearTimeout(this.pollTimer);
72
90
  this.pollTimer = null;
73
91
  }
74
92
  }
75
93
 
76
- private async fetchAndCache(): Promise<void> {
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.debug("Skipping cache write — fetch returned no usable data");
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<