From 1971366423fa61bf5c99023a0a946d79d5bf057e Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 09:47:25 +0200 Subject: [PATCH 01/41] feat(flags-core): preserve datafile fetch timestamps --- .changeset/embedded-flags-fetch-time.md | 8 ++ .../src/index.test.ts | 58 +++++++++++-- .../prepare-flags-definitions/src/index.ts | 3 +- packages/vercel-flags-core/CLAUDE.md | 5 +- .../src/controller/datafile-cache.test.ts | 44 +++++++--- .../src/controller/tagged-data.test.ts | 84 +++++++++++++++++++ .../src/controller/tagged-data.ts | 15 +++- packages/vercel-flags-core/src/types.ts | 6 ++ 8 files changed, 198 insertions(+), 25 deletions(-) create mode 100644 .changeset/embedded-flags-fetch-time.md create mode 100644 packages/vercel-flags-core/src/controller/tagged-data.test.ts diff --git a/.changeset/embedded-flags-fetch-time.md b/.changeset/embedded-flags-fetch-time.md new file mode 100644 index 000000000..57f49a5d2 --- /dev/null +++ b/.changeset/embedded-flags-fetch-time.md @@ -0,0 +1,8 @@ +--- +"@vercel/prepare-flags-definitions": patch +"@vercel/flags-core": patch +--- + +Record `fetchedAt` when a datafile fetch completes and preserve it in generated flag definitions. Loading the bundle retains the original timestamp so the Flags SDK can determine its age. + +Expose optional `fetchedAt` metadata on datafiles. Record it for accepted live updates and preserve valid timestamps when loading provided or bundled definitions, without mutating the input. diff --git a/packages/prepare-flags-definitions/src/index.test.ts b/packages/prepare-flags-definitions/src/index.test.ts index b8f34549f..692f99b5e 100644 --- a/packages/prepare-flags-definitions/src/index.test.ts +++ b/packages/prepare-flags-definitions/src/index.test.ts @@ -1,5 +1,8 @@ -import { readFile } from 'node:fs/promises'; -import { describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { version as pkgVersion } from '../package.json'; import { generateDefinitionsModule, @@ -8,6 +11,16 @@ import { prepareFlagsDefinitions, } from './index'; +const FETCH_TIME = 1_700_000_000_000; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'], now: FETCH_TIME }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + function createOidcToken(projectId: string): string { const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString( 'base64url', @@ -144,6 +157,35 @@ describe('generateDefinitionsModule', () => { }); describe('prepareFlagsDefinitions', () => { + it('embeds fetch completion time and preserves it when loaded later', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'flags-fetched-at-')); + try { + await prepareFlagsDefinitions({ + cwd, + env: { FLAGS: 'vf_server_timestamp' }, + fetch: vi.fn().mockResolvedValue({ + ok: true, + json: async () => { + vi.setSystemTime(FETCH_TIME + 5_000); + return { configUpdatedAt: 123, fetchedAt: 456, definitions: {} }; + }, + }), + }); + vi.setSystemTime(FETCH_TIME + 365 * 24 * 60 * 60 * 1_000); + const url = pathToFileURL( + join(cwd, 'node_modules/@vercel/flags-definitions/index.js'), + ).href; + const bundle = await import(/* @vite-ignore */ url); + expect(bundle.get(hashSdkKey('vf_server_timestamp'))).toEqual({ + configUpdatedAt: 123, + fetchedAt: FETCH_TIME + 5_000, + definitions: {}, + }); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + it('returns { created: false, reason: "no-flags-entries" } when no flags auth is in env', async () => { const result = await prepareFlagsDefinitions({ cwd: '/tmp/test', @@ -174,7 +216,7 @@ describe('prepareFlagsDefinitions', () => { expect(definitionsJs).toMatchInlineSnapshot(` "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}")); const map = { "faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0, @@ -268,7 +310,7 @@ describe('prepareFlagsDefinitions', () => { expect(definitionsJs).toMatchInlineSnapshot(` "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}")); const map = { "3790790d2dc9b23c4539a9f3c49eb5820e4216daebdd7eeee9136f3ceccc31a3": _d0, @@ -416,7 +458,7 @@ describe('prepareFlagsDefinitions', () => { expect(definitionsJs).toMatchInlineSnapshot(` "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}")); const map = { "prj_oidc_test": _d0, @@ -453,7 +495,7 @@ describe('prepareFlagsDefinitions', () => { expect(definitionsJs).toMatchInlineSnapshot(` "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}")); const map = { "faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0, @@ -559,8 +601,8 @@ describe('prepareFlagsDefinitions', () => { expect(definitionsJs).toMatchInlineSnapshot(` "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); - const _d1 = memo(() => JSON.parse("{\\"flag_b\\":{\\"value\\":true}}")); + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}")); + const _d1 = memo(() => JSON.parse("{\\"flag_b\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}")); const map = { "faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0, diff --git a/packages/prepare-flags-definitions/src/index.ts b/packages/prepare-flags-definitions/src/index.ts index 436b3b66b..012f89001 100644 --- a/packages/prepare-flags-definitions/src/index.ts +++ b/packages/prepare-flags-definitions/src/index.ts @@ -199,7 +199,8 @@ async function fetchDatafile( } if (res.ok) { - return res.json() as Promise; + const definitions = (await res.json()) as BundledDefinitions; + return { ...definitions, fetchedAt: Date.now() }; } if (res.status === 404) { diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 99e506f46..78ae0d576 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -272,7 +272,10 @@ The Controller selects the origin. Initial/fallback snapshots are tagged before - `'fetched'` → `'remote'` - `'bundled'` → `'embedded'` -`tagData` mutates the input object in-place via `Object.assign` (callers always pass freshly-created data). +`tagData` returns a shallow copy. Accepted fetched/stream/poll data is stamped with +`fetchedAt`; provided and bundled data preserves valid finite nonnegative timestamps. +Missing/invalid timestamps mean unknown fetch age. Loading data never resets its age, +and equal/older source responses do not replace or retag the cache. ### Usage Tracking diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache.test.ts index bd20a09e9..c85d2b386 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.test.ts @@ -439,8 +439,13 @@ describe('DatafileCache', () => { const snapshot = { ...incoming }; expect(cache.updateFromSource(incoming, origin)).toBeUndefined(); - expect(cache.read()).toBe(incoming); - expect(incoming).toEqual({ ...snapshot, _origin: origin }); + expect(cache.read()).not.toBe(incoming); + expect(cache.read()).toEqual({ + ...snapshot, + _origin: origin, + fetchedAt: 2_000, + }); + expect(incoming).toEqual(snapshot); expect(vi.getTimerCount()).toBe(0); }); @@ -475,14 +480,20 @@ describe('DatafileCache', () => { const snapshot = { ...incoming }; expect(cache.updateFromSource(incoming, 'poll')).toBeUndefined(); - expect(cache.read()).toBe(incoming); - expect(incoming).toEqual({ ...snapshot, _origin: 'poll' }); + const accepted = cache.read(); + expect(accepted).not.toBe(incoming); + expect(accepted).toEqual({ + ...snapshot, + _origin: 'poll', + fetchedAt: 2_000, + }); + expect(incoming).toEqual(snapshot); expect(cache.read()?.definitions).toBe(incoming.definitions); const nextError = new Error('second outage'); cache.fail(nextError); vi.setSystemTime(2_100); - expect(cache.read()).toBe(incoming); + expect(cache.read()).toBe(accepted); vi.setSystemTime(2_101); expect(() => cache.read()).toThrow(nextError); }); @@ -497,11 +508,13 @@ describe('DatafileCache', () => { const incoming = response({ ...overrides, configUpdatedAt: 2 }); cache.updateFromSource(incoming, 'stream'); - expect(cache.read()).toBe(incoming); - expect(incoming).toEqual({ - ...response({ ...overrides, configUpdatedAt: 2 }), + expect(cache.read()).not.toBe(incoming); + expect(cache.read()).toEqual({ + ...incoming, _origin: 'stream', + fetchedAt: 1_000, }); + expect(incoming).toEqual(response({ ...overrides, configUpdatedAt: 2 })); }); it.each([ @@ -646,8 +659,13 @@ describe('DatafileCache', () => { cache.seed(oldResponse); const replacement = response({ configUpdatedAt: 2 }); cache.updateFromSource(replacement, 'poll'); - expect(cache.read()).toBe(replacement); - expect(cache.read()?._origin).toBe('poll'); + const accepted = cache.read(); + expect(accepted).not.toBe(replacement); + expect(accepted).toEqual({ + ...replacement, + _origin: 'poll', + fetchedAt: 1_000, + }); const error = new Error('replacement outage'); cache.fail(error); vi.setSystemTime(1_050); @@ -656,19 +674,19 @@ describe('DatafileCache', () => { if (staleIfErrorMs === 0) { expect(() => cache.read()).toThrow(error); } else { - expect(cache.read()).toBe(replacement); + expect(cache.read()).toBe(accepted); } expect(oldResponse._origin).toBe('bundled'); vi.setSystemTime(1_100); if (staleIfErrorMs === 0) { expect(() => cache.read()).toThrow(error); } else { - expect(cache.read()).toBe(replacement); + expect(cache.read()).toBe(accepted); } vi.setSystemTime(1_101); expect(() => cache.read()).toThrow(error); expect(cache.tryConfirm(replacement)).toBe(true); - expect(cache.read()).toBe(replacement); + expect(cache.read()).toBe(accepted); }); }); }); diff --git a/packages/vercel-flags-core/src/controller/tagged-data.test.ts b/packages/vercel-flags-core/src/controller/tagged-data.test.ts new file mode 100644 index 000000000..1b14a766d --- /dev/null +++ b/packages/vercel-flags-core/src/controller/tagged-data.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DatafileInput } from '../types'; +import { tagData } from './tagged-data'; + +const NOW = 1_700_000_000_000; + +const datafile: DatafileInput = { + projectId: 'prj_test', + environment: 'production', + definitions: {}, + configUpdatedAt: NOW - 60_000, +}; + +beforeEach(() => { + vi.useFakeTimers({ now: NOW }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('tagData', () => { + it.each([ + 'fetched', + 'poll', + 'stream', + ] as const)('records each %s arrival without mutating the input', (origin) => { + const input = Object.freeze({ ...datafile, fetchedAt: NOW - 5_000 }); + const tagged = tagData(input, origin); + + expect(tagged).not.toBe(input); + expect(tagged).toEqual({ ...datafile, _origin: origin, fetchedAt: NOW }); + expect(tagged).not.toHaveProperty('_lastSeen'); + + vi.setSystemTime(NOW + 1_000); + expect(tagData(input, origin).fetchedAt).toBe(NOW + 1_000); + expect(tagged.fetchedAt).toBe(NOW); + expect(input.fetchedAt).toBe(NOW - 5_000); + expect(tagged.configUpdatedAt).toBe(datafile.configUpdatedAt); + }); + + it.each([ + 'provided', + 'bundled', + ] as const)('keeps %s freshness unknown, including after a fetch', (origin) => { + const input = { ...datafile }; + expect(tagData(input, origin).fetchedAt).toBeUndefined(); + tagData(input, 'fetched'); + vi.setSystemTime(NOW + 1_000); + const tagged = tagData(input, origin); + + expect(tagged).not.toBe(input); + expect(tagged).toEqual({ + ...datafile, + _origin: origin, + }); + expect(tagged).not.toHaveProperty('_lastSeen'); + }); + + it.each([ + 'provided', + 'bundled', + ] as const)('preserves %s timestamps, including zero, without resetting age', (origin) => { + for (const fetchedAt of [0, NOW - 30_000]) { + const input = Object.freeze({ ...datafile, fetchedAt }); + vi.setSystemTime(NOW + 10_000); + expect(tagData(input, origin)).toEqual({ ...input, _origin: origin }); + expect(input.fetchedAt).toBe(fetchedAt); + } + }); + + it.each([ + undefined, + NaN, + Infinity, + -Infinity, + -1, + '1700000000000', + ])('treats invalid or missing fetchedAt=%s as unknown', (fetchedAt) => { + const input = { ...datafile, fetchedAt } as DatafileInput; + expect(tagData(input, 'provided')).not.toHaveProperty('fetchedAt'); + expect(tagData(input, 'bundled')).not.toHaveProperty('fetchedAt'); + }); +}); diff --git a/packages/vercel-flags-core/src/controller/tagged-data.ts b/packages/vercel-flags-core/src/controller/tagged-data.ts index d86d76abe..f87824c22 100644 --- a/packages/vercel-flags-core/src/controller/tagged-data.ts +++ b/packages/vercel-flags-core/src/controller/tagged-data.ts @@ -15,10 +15,21 @@ export type TaggedData = DatafileInput & { }; /** - * Tags a DatafileInput with its origin. + * Tags a DatafileInput with metadata. */ export function tagData(data: DatafileInput, origin: DataOrigin): TaggedData { - return Object.assign(data, { _origin: origin }) as TaggedData; + const tagged: TaggedData = { ...data, _origin: origin }; + if (origin === 'fetched' || origin === 'poll' || origin === 'stream') { + tagged.fetchedAt = Date.now(); + } else if ( + typeof data.fetchedAt !== 'number' || + !Number.isFinite(data.fetchedAt) || + data.fetchedAt < 0 + ) { + // Legacy data without a valid timestamp has unknown freshness. + delete tagged.fetchedAt; + } + return tagged; } /** diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 3de028fa6..5e60dbbac 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -35,6 +35,12 @@ export type DatafileInput = Packed.Data & { * Some older responses might return a string instead of a number. Both will be timestamps. */ configUpdatedAt?: number | string; + /** + * When this datafile was successfully fetched, as Unix epoch milliseconds. + * Preserved when bundled, serialized, or supplied to another client. + * Omit when the original fetch time is unknown; loading data does not reset it. + */ + fetchedAt?: number; /** Version number of the data */ revision?: number; }; From 88c323c9539661fef385f1381ff7e3e89776fdc7 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 09:48:12 +0200 Subject: [PATCH 02/41] feat(flags-core): add header-driven Vercel mode on the shared cache --- .changeset/header-driven-vercel-mode.md | 5 + packages/vercel-flags-core/CLAUDE.md | 24 ++- .../src/controller/datafile-cache.ts | 10 + .../src/controller/header-source.ts | 199 ++++++++++++++++++ .../vercel-flags-core/src/controller/index.ts | 47 ++++- .../src/controller/normalized-options.ts | 31 ++- packages/vercel-flags-core/src/types.ts | 2 +- .../src/utils/usage/flags-config-read.ts | 4 +- 8 files changed, 307 insertions(+), 15 deletions(-) create mode 100644 .changeset/header-driven-vercel-mode.md create mode 100644 packages/vercel-flags-core/src/controller/header-source.ts diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md new file mode 100644 index 000000000..d71c9635c --- /dev/null +++ b/.changeset/header-driven-vercel-mode.md @@ -0,0 +1,5 @@ +--- +"@vercel/flags-core": minor +--- + +Add a header-driven `vercel` client mode that uses timestamps from the `x-vercel-flags-config-versions` or `flags-config-versions` request header instead of streaming or polling. The `vercel` option defaults to `process.env.VERCEL === '1'` and can be explicitly enabled or disabled. Vercel mode loads provided or bundled definitions during initialization, uses cached data when no usable header is available, and fetches on the first read if the cache is empty. Disabling both streaming and polling preserves offline behavior. Reuse fresh definitions, refresh in the background for up to `staleWhileRevalidateMs` (10 seconds by default) since the cached configuration was last successfully fetched or confirmed by a matching request header at the highest observed version, and block for a refresh when that freshness expires or is unknown. Set `staleWhileRevalidateMs` to `0` to disable background stale serving. Refresh failures use the shared `staleIfErrorMs` allowance; set that to `0` to propagate failures immediately. Datafiles expose an optional `fetchedAt` timestamp that survives serialization and reuse. Bundled and provided definitions preserve valid timestamps; definitions without one start with unknown age. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 78ae0d576..088ade5e6 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -18,6 +18,7 @@ src/ ├── controller/ # Controller (state machine) and I/O sources │ ├── index.ts # Controller class │ ├── stream-source.ts # StreamSource (wraps stream-connection) +│ ├── header-source.ts # Request version checks and on-demand refresh │ ├── polling-source.ts # PollingSource (wraps fetch-datafile) │ ├── bundled-source.ts # BundledSource (wraps read-bundled-definitions) │ ├── stream-connection.ts # Low-level NDJSON stream connection @@ -43,7 +44,7 @@ src/ ``` createClient(sdkKey, options) → Controller (state machine, selects data origin and coordinates sources/cache) - → StreamSource / PollingSource / BundledSource (emit raw DatafileInput) + → StreamSource / PollingSource / HeaderSource / BundledSource (raw DatafileInput) → create-raw-client (ID-based indirection for 'use cache' support) → controller-fns (lookup by ID, evaluate, report) → FlagsClient (public API) @@ -99,7 +100,9 @@ type ControllerOptions = { datafile?: Datafile; // Initial datafile for immediate reads stream?: boolean | { initTimeoutMs: number }; // default: true (3000ms) polling?: boolean | { intervalMs: number; initTimeoutMs: number }; // default: true (30s interval, 3s timeout) - staleIfError?: number; // Seconds of fallback after stream/poll failure; default: Infinity + vercel?: boolean; // default: process.env.VERCEL === '1'; replaces stream/poll at runtime + staleWhileRevalidateMs?: number; // header refresh only; default: 10_000 + staleIfError?: number; // Seconds of fallback after update failure; default: Infinity buildStep?: boolean; // Override build step auto-detection metricEnvironment?: string; // Environment attached to ingested evaluation metrics waitUntil?: (promise: Promise) => void; // default: @vercel/functions waitUntil @@ -123,7 +126,20 @@ Behavior differs based on environment: Build-step reads are deduplicated: data is loaded once via a shared promise (`buildDataPromise`) and all concurrent `evaluate()` calls share the result. The entire build counts as a single tracked read event (`buildReadTracked` flag in Controller). -**Runtime** (default, or `buildStep: false`): +**Vercel runtime** (`vercel: true`, default when `VERCEL=1`): +- Load provided or bundled definitions during initialization, then select Vercel mode. +- Do not start stream/poll; the first read fetches if the cache is empty. +- HeaderSource parses the request's project version and owns `highestObserved` and `lastSeen`. +- A matching header confirms freshness only when no newer version has been observed. +- A newer header refreshes in the background within `staleWhileRevalidateMs` of the latest + accepted fetch or matching header; unknown/expired freshness requires a blocking refresh. +- Every returned entry passes through `DatafileCache.read()`. Refresh errors use its + `staleIfErrorMs` allowance; expiry forces blocking recovery on the next newer-header read. +- Missing/malformed headers use cached data without fetching, subject to stale-if-error. +- `getDatafile()` remains a snapshot read: it enforces the same failure policy but does + not inspect request headers. Disabling both stream and polling selects offline mode. + +**Other runtime** (default outside Vercel, or `vercel: false`): 1. **Stream** - Real-time updates via NDJSON streaming, wait up to `initTimeoutMs` 2. **Polling** - Interval-based HTTP requests, wait up to `initTimeoutMs` 3. **Provided datafile** - Use `options.datafile` if provided @@ -311,7 +327,7 @@ The DatafileCache rejects incoming data (from stream or poll) if its `configUpda `DatafileCache.read()` is the only full-entry read. The cache is configured once with the internal `staleIfErrorMs`, normalized from the public `staleIfError` option in seconds. Evaluations and `getDatafile()` share the same serving -boundary. `hasData` and `revision` expose coordination metadata even after expiry, +boundary. `hasData`, `revision`, and `metadata` expose coordination metadata even after expiry, so retained data is not replaced by fallback and stream reconnects can still send `X-Revision`. `seed()` never clears failure. Accepted source updates or valid version/revision confirmations clear it; repeated errors/disconnects do not renew diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 97752a79a..37b32580b 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -6,6 +6,8 @@ type Confirmation = Pick< 'configUpdatedAt' | 'revision' | 'projectId' | 'environment' >; +export type CacheMetadata = Confirmation & Pick; + /** * Parses a configUpdatedAt value (number or string) into a numeric timestamp. * Returns undefined if the value is missing or cannot be parsed. @@ -35,6 +37,14 @@ export class DatafileCache { return this.data?.revision; } + /** Freshness checks can inspect retained metadata even after serving expires. */ + get metadata(): CacheMetadata | undefined { + if (!this.data) return undefined; + const { projectId, environment, configUpdatedAt, revision, fetchedAt } = + this.data; + return { projectId, environment, configUpdatedAt, revision, fetchedAt }; + } + /** Stores initial or fallback data without confirming recovery from a failure. */ seed(data: TaggedData): void { this.data = data; diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts new file mode 100644 index 000000000..b1be3d5c9 --- /dev/null +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -0,0 +1,199 @@ +import type { BundledDefinitions, DatafileInput, Metrics } from '../types'; +import { getRequestContext } from '../utils/request-context'; +import type { CacheMetadata, DatafileCache } from './datafile-cache'; +import { fetchDatafile } from './fetch-datafile'; +import type { NormalizedOptions } from './normalized-options'; +import type { TaggedData } from './tagged-data'; +import { TypedEmitter } from './typed-emitter'; + +export type HeaderSourceEvents = { + data: (data: DatafileInput) => void; + error: (error: Error) => void; +}; + +/** + * Manages a lazy pulling of flag data from the flags service using the version header. + */ +export class HeaderSource extends TypedEmitter { + private options: NormalizedOptions; + private abortController: AbortController | undefined; + private promise: Promise | undefined; + private highestObserved = 0; + private lastSeen: { version: number; at: number } | undefined; + + constructor(options: NormalizedOptions) { + super(); + + this.options = options; + } + + private fetchDatafile(): Promise { + // Share only the transport work, not request-specific freshness decisions. + if (this.promise) return this.promise; + + const abortController = new AbortController(); + this.abortController = abortController; + this.promise = fetchDatafile({ + ...this.options, + signal: abortController.signal, + }) + .then((data) => { + // A transport may finish after stop() even if it ignores cancellation. + abortController.signal.throwIfAborted(); + this.emit('data', data); + return data; + }) + .catch((error) => { + if (!abortController.signal.aborted) this.emit('error', error); + throw error; + }) + .finally(() => { + // An older, aborted fetch must not clear a newer request's work. + if (this.abortController === abortController) { + this.promise = undefined; + this.abortController = undefined; + } + }); + + return this.promise; + } + + private getUpdatedAtHeader(projectId: string, header: string | undefined) { + if (!header) { + return; + } + + const prefix = `flags_${projectId}=`; + const value = header + .split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix)) + ?.slice(prefix.length); + const timestamp = Number(value); + + return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : undefined; + } + + private async refresh( + cache: DatafileCache, + ): Promise<[TaggedData, Metrics['cacheStatus']]> { + const pending = this.fetchDatafile(); + const signal = this.abortController!.signal; + try { + await pending; + signal.throwIfAborted(); + } catch (error) { + if (signal.aborted) throw error; + // The controller recorded the failure; the cache decides whether to serve it. + const stale = cache.read(); + if (!stale) throw error; + return [stale, 'STALE']; + } + return [cache.read()!, 'MISS']; + } + + private revalidate(): void { + const pending = this.fetchDatafile(); + const signal = this.abortController?.signal; + const background = pending.catch((error) => { + if (!signal?.aborted) { + console.error('@vercel/flags-core: Header refresh failed:', error); + } + }); + + try { + this.options.waitUntil(background); + } catch { + // Registration is best-effort; the handled refresh continues regardless. + } + } + + private async resolveData( + cache: DatafileCache, + current: CacheMetadata, + updatedAtHeader: number | undefined, + ): Promise<[TaggedData, Metrics['cacheStatus']] | undefined> { + if (!current.configUpdatedAt || !updatedAtHeader) return; + + const currentUpdatedAt = Number(current.configUpdatedAt); + if (updatedAtHeader <= currentUpdatedAt) { + return [cache.read()!, 'HIT']; + } + + const freshAt = Math.max( + current.fetchedAt ?? -Infinity, + this.lastSeen?.version === currentUpdatedAt + ? this.lastSeen.at + : -Infinity, + ); + const { staleWhileRevalidateMs } = this.options; + if ( + staleWhileRevalidateMs > 0 && + Date.now() - freshAt <= staleWhileRevalidateMs + ) { + let stale: TaggedData | undefined; + try { + stale = cache.read(); + } catch { + // Expired stale-if-error requires a blocking recovery attempt below. + } + if (stale) { + this.revalidate(); + return [stale, 'STALE']; + } + } + + return this.refresh(cache); + } + + private observe(version: number, currentVersion: number): boolean { + this.highestObserved = Math.max(this.highestObserved, version); + // Once invalidated, an older matching header cannot renew freshness. + if (version !== currentVersion || version !== this.highestObserved) + return false; + this.lastSeen = { version, at: Date.now() }; + return true; + } + + async read( + cache: DatafileCache, + ): Promise<[TaggedData, Metrics['cacheStatus']] | undefined> { + // Capture the request header before a cold fetch discovers its project. + const { headers } = getRequestContext(); + const header = + headers?.['x-vercel-flags-config-versions'] ?? + headers?.['flags-config-versions']; + const current = cache.metadata; + const fetched = current ? undefined : await this.refresh(cache); + const metadata = current ?? cache.metadata!; + const updatedAtHeader = this.getUpdatedAtHeader(metadata.projectId, header); + if ( + updatedAtHeader && + this.observe(updatedAtHeader, Number(metadata.configUpdatedAt)) + ) { + cache.tryConfirm(metadata); + } + + if (fetched) return fetched; + return this.resolveData(cache, metadata, updatedAtHeader); + } + + isAvailable(): boolean { + // Explicit offline mode disables header-driven refreshes too. + return ( + this.options.vercel && + (this.options.stream.enabled || this.options.polling.enabled) + ); + } + + /** + * Abort the current header-driven fetch and discard its pending work. + */ + stop(): void { + this.abortController?.abort(); + this.abortController = undefined; + this.promise = undefined; + this.lastSeen = undefined; + this.highestObserved = 0; + } +} diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 44a2140f2..2c6bb898b 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -13,6 +13,7 @@ import { unauthorizedMessage } from './auth'; import { BundledSource } from './bundled-source'; import { DatafileCache } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; +import { HeaderSource } from './header-source'; import { type ControllerOptions, type NormalizedOptions, @@ -42,6 +43,7 @@ type State = | 'initializing:fallback' | 'streaming' | 'polling' + | 'vercel' | 'degraded' | 'build:loading' | 'build:ready' @@ -70,6 +72,11 @@ type State = * - Uses polling exclusively * - Same fallback chains as streaming mode * + * **Runtime — Vercel mode** (vercel enabled, with stream or polling enabled): + * - Loads provided/bundled data before selecting the mode; no startup network + * - HeaderSource checks request versions and refreshes when needed + * - Cache applies version acceptance and stale-if-error to all served data + * * **Runtime — offline mode** (neither stream nor polling): * - Init fallback: constructor datafile → bundled → one-time fetch → throw * - Read fallback: in-memory value → constructor datafile → bundled → one-time fetch → throw @@ -93,6 +100,7 @@ export class Controller implements ControllerInterface { private streamSource: StreamSource; private pollingSource: PollingSource; private bundledSource: BundledSource; + private headerSource: HeaderSource; // Usage tracking private usageTracker: UsageTracker; @@ -117,6 +125,7 @@ export class Controller implements ControllerInterface { ); this.pollingSource = new PollingSource(this.options); + this.headerSource = new HeaderSource(this.options); this.bundledSource = new BundledSource({ auth: this.options.auth, @@ -159,13 +168,16 @@ export class Controller implements ControllerInterface { this.transition('degraded'); } }; - private onStreamError = (error: Error) => { + private onSourceError = (error: Error) => { this.cache.fail(error); }; private onPollData = (data: DatafileInput) => { this.unauthorized = false; this.cache.updateFromSource(data, 'poll'); }; + private onHeaderData = (data: DatafileInput) => { + this.cache.updateFromSource(data, 'fetched'); + }; private onPollError = (error: Error) => { this.noteUnauthorized(error); this.cache.fail(error); @@ -181,9 +193,11 @@ export class Controller implements ControllerInterface { this.streamSource.on('primed', this.onStreamPrimed); this.streamSource.on('connected', this.onStreamConnected); this.streamSource.on('disconnected', this.onStreamDisconnected); - this.streamSource.on('error', this.onStreamError); + this.streamSource.on('error', this.onSourceError); this.pollingSource.on('data', this.onPollData); this.pollingSource.on('error', this.onPollError); + this.headerSource.on('data', this.onHeaderData); + this.headerSource.on('error', this.onSourceError); } private unwireSourceEvents(): void { @@ -191,9 +205,11 @@ export class Controller implements ControllerInterface { this.streamSource.off('primed', this.onStreamPrimed); this.streamSource.off('connected', this.onStreamConnected); this.streamSource.off('disconnected', this.onStreamDisconnected); - this.streamSource.off('error', this.onStreamError); + this.streamSource.off('error', this.onSourceError); this.pollingSource.off('data', this.onPollData); this.pollingSource.off('error', this.onPollError); + this.headerSource.off('data', this.onHeaderData); + this.headerSource.off('error', this.onSourceError); } // --------------------------------------------------------------------------- @@ -215,6 +231,8 @@ export class Controller implements ControllerInterface { return 'streaming'; case 'polling': return 'polling'; + case 'vercel': + return 'vercel'; default: return 'offline'; } @@ -230,6 +248,7 @@ export class Controller implements ControllerInterface { * Build step: datafile → bundled → one-time fetch * Streaming mode: stream → datafile → bundled * Polling mode (no stream): poll → datafile → bundled + * Vercel mode: datafile → bundled; fetch only on a read * Offline mode (neither): datafile → bundled → one-time fetch */ async initialize(): Promise { @@ -249,14 +268,22 @@ export class Controller implements ControllerInterface { // send the revision to the stream and potentially get a lightweight // "primed" response instead of a full datafile. if (!this.cache.hasData) { + this.transition('initializing:fallback'); + let bundled: DatafileInput | undefined; try { - const bundled = await this.bundledSource.tryLoad(); - if (bundled) { - this.cache.seed(tagData(bundled, 'bundled')); - } + bundled = await this.bundledSource.tryLoad(); } catch { // Bundled definitions not available — proceed without revision } + if (this.state === 'shutdown') { + throw new Error('@vercel/flags-core: Client is shut down'); + } + if (bundled) this.cache.seed(tagData(bundled, 'bundled')); + } + + if (this.headerSource.isAvailable()) { + this.transition('vercel'); + return; } // If we already have data (from provided datafile or bundled definitions), @@ -338,6 +365,7 @@ export class Controller implements ControllerInterface { this.unwireSourceEvents(); this.streamSource.stop(); this.pollingSource.stop(); + this.headerSource.stop(); this.cache.clear(); if (this.options.datafile) { this.cache.seed(tagData(this.options.datafile, 'provided')); @@ -430,6 +458,11 @@ export class Controller implements ControllerInterface { return this.resolveDataForBuildStep(); } + if (this.state === 'vercel') { + const result = await this.headerSource.read(this.cache); + if (result) return result; + } + const data = this.cache.read(); if (data) { const cacheStatus = this.isConnected ? 'HIT' : 'STALE'; diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index 78a77a002..288bc50f8 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -12,6 +12,7 @@ const DEFAULT_STREAM_INIT_TIMEOUT_MS = 3000; const DEFAULT_POLLING_INTERVAL_MS = 30_000; const MIN_POLLING_INTERVAL_MS = 30_000; const DEFAULT_POLLING_INIT_TIMEOUT_MS = 3_000; +const DEFAULT_STALE_WHILE_REVALIDATE_MS = 10_000; /** * Configuration options for Controller @@ -45,9 +46,25 @@ export type ControllerOptions = { */ polling?: boolean | PollingOptions; + /** + * Use request version headers instead of streaming or polling at runtime. + * Initialization starts no network activity; reads fetch only when needed. + * Disabling both stream and polling still selects offline mode. + * @default process.env.VERCEL === '1' + */ + vercel?: boolean; + + /** + * How long header-driven reads may serve cached data while refreshing in the + * background, measured from its last fetch or matching version header. + * Must be a finite, non-negative number. Set to 0 to always block on refresh. + * @default 10000 + */ + staleWhileRevalidateMs?: number; + /** * How long runtime reads may use cached data after the first consecutive - * stream/poll failure or stream disconnect. Accepts nonnegative seconds or Infinity. + * stream/poll/header failure or stream disconnect. Accepts nonnegative seconds or Infinity. * Fractional seconds are supported. * Zero disables fallback immediately; positive windows include the deadline. * Accepted updates, matching versions, or matching stream primed revisions @@ -103,6 +120,8 @@ export type NormalizedOptions = { datafile: DatafileInput | undefined; stream: { enabled: boolean; initTimeoutMs: number }; polling: { enabled: boolean; intervalMs: number; initTimeoutMs: number }; + vercel: boolean; + staleWhileRevalidateMs: number; staleIfErrorMs: number; buildStep: boolean; fetch: typeof globalThis.fetch; @@ -159,11 +178,21 @@ export function normalizeOptions( }; } + const staleWhileRevalidateMs = + options.staleWhileRevalidateMs ?? DEFAULT_STALE_WHILE_REVALIDATE_MS; + if (!Number.isFinite(staleWhileRevalidateMs) || staleWhileRevalidateMs < 0) { + throw new Error( + '@vercel/flags-core: staleWhileRevalidateMs must be a finite, non-negative number.', + ); + } + return { auth: options.auth, datafile: options.datafile, stream, polling, + vercel: options.vercel ?? process.env.VERCEL === '1', + staleWhileRevalidateMs, staleIfErrorMs: staleIfError * 1000, buildStep, fetch: options.fetch ?? globalThis.fetch, diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 5e60dbbac..375767b2c 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -79,7 +79,7 @@ export type Metrics = { /** Whether the stream is currently connected */ connectionState: 'connected' | 'disconnected'; /** The current operating mode of the client */ - mode: 'streaming' | 'polling' | 'build' | 'offline'; + mode: 'streaming' | 'polling' | 'build' | 'vercel' | 'offline'; /** Time in ms for the pure flag evaluation logic (only present on EvaluationResult) */ evaluationMs?: number; }; diff --git a/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts b/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts index c96573431..9b9930f6d 100644 --- a/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts +++ b/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts @@ -16,7 +16,7 @@ export interface TrackReadOptions { /** Timestamp when the config was last updated */ configUpdatedAt?: number; /** The mode the SDK is operating in */ - mode?: 'poll' | 'stream' | 'build' | 'offline'; + mode?: 'poll' | 'stream' | 'build' | 'vercel' | 'offline'; /** Revision of the config */ revision?: number; } @@ -36,7 +36,7 @@ export class FlagsConfigReadEvent implements UsageEvent { duration?: number; configUpdatedAt?: number; configOrigin?: 'in-memory' | 'embedded' | 'poll' | 'stream' | 'constructor'; - mode?: 'poll' | 'stream' | 'build' | 'offline'; + mode?: TrackReadOptions['mode']; revision?: string; environment?: string; }; From 121f566cc24163b8051da76a4c7cde585d258879 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 09:48:35 +0200 Subject: [PATCH 03/41] test(flags-core): cover Vercel mode selection and refreshes --- .../vercel-flags-core/src/black-box.test.ts | 82 ++- .../src/vercel-mode.black-box.test.ts | 635 ++++++++++++++++++ 2 files changed, 704 insertions(+), 13 deletions(-) create mode 100644 packages/vercel-flags-core/src/vercel-mode.black-box.test.ts diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 2c16fc115..6b2c9b8cb 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -188,6 +188,61 @@ describe('Controller (black-box)', () => { delete process.env.NEXT_PHASE; }); + it.each([ + ['poll', 3, false, 3], + ['poll', 2, true, 2], + ['poll', 1, true, 2], + ['stream', 3, false, 3], + ['stream', 2, true, 2], + ['stream', 1, true, 2], + ] as const)('applies the version guard to %s version %i', async (source, configUpdatedAt, expectedValue, expectedVersion) => { + const stream = createMockStream(); + const incoming = makeBundled({ + configUpdatedAt, + definitions: { + flagA: { + environments: { production: 0 }, + variants: [false, true], + }, + }, + }); + const dataFetch = vi.fn(async () => Response.json(incoming)); + fetchMock.mockImplementation((input, init) => { + const url = String(input); + if (url.endsWith('/v1/stream')) return stream.response; + if (url.endsWith('/v1/datafile')) return dataFetch(input, init); + if (url.endsWith('/v1/ingest')) return Promise.resolve(new Response()); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + const client = createClient(sdkKey, { + datafile: makeBundled({ configUpdatedAt: 2 }), + fetch: fetchMock, + buildStep: false, + stream: source === 'stream', + polling: source === 'poll', + }); + const cleanupContext = setRequestContext({}); + try { + const initial = client.evaluate('flagA'); + if (source === 'stream') { + stream.push({ type: 'datafile', data: incoming }); + } + expect((await initial).value).toBe(expectedValue); + expect((await client.getDatafile()).configUpdatedAt).toBe( + expectedVersion, + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(dataFetch).toHaveBeenCalledTimes(source === 'poll' ? 1 : 0); + } finally { + cleanupContext(); + try { + await client.shutdown(); + } finally { + stream.close(); + } + } + }); + afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); @@ -2358,10 +2413,11 @@ describe('Controller (black-box)', () => { streams[1]!.push({ type: 'datafile', data: olderData }); await vi.advanceTimersByTimeAsync(0); - // Should still have newer data (configUpdatedAt guard rejected older) + // The version guard rejects the older response after reconnection. const result2 = await client.evaluate('flagA'); expect(result2.value).toBe(true); // still variant 1 expect(result2.metrics?.connectionState).toBe('connected'); + expect((await client.getDatafile()).configUpdatedAt).toBe(2000); await client.shutdown(); }); @@ -2781,9 +2837,10 @@ describe('Controller (black-box)', () => { stream.push({ type: 'datafile', data: olderDatafile }); await vi.advanceTimersByTimeAsync(50); - // Should still have newer data (older message was rejected) + // Keep the newer data; the older message was rejected. const result = await client.evaluate('flagA', undefined, undefined); expect(result.value).toBe(true); // variant 1 = newer + expect((await client.getDatafile()).configUpdatedAt).toBe(2000); stream.close(); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -2838,8 +2895,6 @@ describe('Controller (black-box)', () => { }); it('should skip stream data with equal configUpdatedAt', async () => { - vi.useRealTimers(); - const data1 = makeBundled({ configUpdatedAt: 1000, definitions: { @@ -2877,15 +2932,17 @@ describe('Controller (black-box)', () => { const initPromise = client.initialize(); stream.push({ type: 'datafile', data: data1 }); - await new Promise((r) => setTimeout(r, 10)); + await vi.advanceTimersByTimeAsync(0); await initPromise; + expect((await client.evaluate('flagA')).value).toBe(false); stream.push({ type: 'datafile', data: data2 }); - await new Promise((r) => setTimeout(r, 50)); + await vi.advanceTimersByTimeAsync(0); - // Should have kept first data (equal configUpdatedAt is not newer) + // Keep the first data; equal configUpdatedAt is not newer. const result = await client.evaluate('flagA'); expect(result.value).toBe(false); // variant 0 = data1 + expect((await client.getDatafile()).configUpdatedAt).toBe(1000); stream.close(); await client.shutdown(); @@ -2944,9 +3001,7 @@ describe('Controller (black-box)', () => { await client.shutdown(); }); - it('should handle configUpdatedAt as string', async () => { - vi.useRealTimers(); - + it('should reject older stream responses with string configUpdatedAt', async () => { const newerDatafile = { ...makeBundled({ definitions: { @@ -2988,15 +3043,16 @@ describe('Controller (black-box)', () => { const initPromise = client.initialize(); stream.push({ type: 'datafile', data: newerDatafile }); - await new Promise((r) => setTimeout(r, 10)); + await vi.advanceTimersByTimeAsync(0); await initPromise; + expect((await client.evaluate('flagA')).value).toBe(true); stream.push({ type: 'datafile', data: olderDatafile }); - await new Promise((r) => setTimeout(r, 50)); + await vi.advanceTimersByTimeAsync(0); - // Should still have newer data const result = await client.evaluate('flagA'); expect(result.value).toBe(true); // variant 1 = newer + expect((await client.getDatafile()).configUpdatedAt).toBe('2000'); stream.close(); await client.shutdown(); diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts new file mode 100644 index 000000000..1751d279b --- /dev/null +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -0,0 +1,635 @@ +/** Public-API coverage: real client, controller, header source and fetch helper. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type BundledDefinitions, + createClient, + type FlagsClient, +} from './index.default'; +import { setRequestContext } from './test-utils'; +import { readBundledDefinitions } from './utils/read-bundled-definitions'; + +// Only the filesystem boundary is replaced; no controller/source is mocked. +vi.mock('./utils/read-bundled-definitions', () => ({ + readBundledDefinitions: vi.fn(), +})); + +const TIMESTAMP = 1_700_000_000_000; +const PROJECT_ID = 'prj_header_test'; +const HEADER = 'x-vercel-flags-config-versions'; +const SDK_KEY = 'vf_server_header_test'; + +function datafile(timestamp = TIMESTAMP, enabled = false): BundledDefinitions { + return { + definitions: { + feature: { + environments: { production: enabled ? 1 : 0 }, + variants: [false, true], + }, + }, + segments: {}, + projectId: PROJECT_ID, + environment: 'production', + configUpdatedAt: timestamp, + digest: `digest-${timestamp}`, + revision: timestamp - TIMESTAMP + 1, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const clients = new Set(); +const dataFetch = vi.fn(); +const transport = vi.fn(); +let cleanupContext = () => {}; + +function mockDatafileResponse(timestamp: number, enabled = false) { + dataFetch.mockResolvedValueOnce(Response.json(datafile(timestamp, enabled))); +} + +function setVersion(timestamp?: number | string) { + cleanupContext(); + cleanupContext = setRequestContext( + timestamp === undefined + ? {} + : { [HEADER]: `flags_other=1;flags_${PROJECT_ID}=${timestamp}` }, + ); +} + +function client(options: Parameters[1] = {}) { + const instance = createClient(SDK_KEY, { + datafile: datafile(), + buildStep: false, + fetch: transport, + ...options, + }); + clients.add(instance); + return instance; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(TIMESTAMP); + vi.stubEnv('VERCEL_ENV', 'production'); + vi.stubEnv('VERCEL', '1'); + vi.mocked(readBundledDefinitions).mockReset(); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: null, + state: 'missing-file', + }); + dataFetch.mockReset(); + dataFetch.mockRejectedValue(new Error('Unexpected datafile fetch')); + transport.mockReset(); + transport.mockImplementation((input, init) => { + const url = String(input); + if (url === 'https://flags.vercel.com/v1/datafile') { + return dataFetch(input, init); + } + if (url === 'https://flags.vercel.com/v1/ingest') { + return Promise.resolve(new Response()); + } + return Promise.reject(new Error(`Unexpected request: ${url}`)); + }); + setVersion(TIMESTAMP); +}); + +afterEach(async () => { + try { + await Promise.all([...clients].map((instance) => instance.shutdown())); + } finally { + clients.clear(); + cleanupContext(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + } +}); + +describe('Vercel mode (black-box)', () => { + it.each([ + [undefined, undefined, 'polling'], + ['0', undefined, 'polling'], + ['true', undefined, 'polling'], + ['1', undefined, 'vercel'], + ['1', false, 'polling'], + [undefined, true, 'vercel'], + ] as const)('uses %s with vercel=%s to select %s mode', async (env, vercel, mode) => { + vi.stubEnv('VERCEL', env); + mockDatafileResponse(TIMESTAMP, true); + const instance = client({ vercel, stream: false, datafile: undefined }); + await instance.initialize(); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 1 : 0); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('checks the bundle during initialization without request context, then shares the first read fetch', async () => { + setVersion(undefined); + const instance = client({ datafile: undefined }); + await instance.initialize(); + expect(readBundledDefinitions).toHaveBeenCalledTimes(1); + expect(transport).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + + setVersion(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const reads = [instance.evaluate('feature'), instance.evaluate('feature')]; + await vi.advanceTimersByTimeAsync(0); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + for (const result of await Promise.all(reads)) { + expect(result).toMatchObject({ + value: true, + metrics: { mode: 'vercel', source: 'remote', cacheStatus: 'MISS' }, + }); + } + expect(readBundledDefinitions).toHaveBeenCalledTimes(1); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + await vi.advanceTimersByTimeAsync(60_000); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect( + transport.mock.calls.some(([url]) => String(url).includes('/stream')), + ).toBe(false); + }); + + it.each([ + 'provided', + 'bundled', + 'empty', + ] as const)('uses the %s cache without a header, fetching only when empty', async (cache) => { + setVersion(undefined); + if (cache === 'bundled') { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: datafile(), + state: 'ok', + }); + } + mockDatafileResponse(TIMESTAMP, true); + const instance = client({ + datafile: cache === 'provided' ? datafile() : undefined, + }); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: cache === 'empty', + metrics: { + mode: 'vercel', + cacheStatus: cache === 'empty' ? 'MISS' : 'STALE', + }, + }); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect(dataFetch).toHaveBeenCalledTimes(cache === 'empty' ? 1 : 0); + }); + + it('recovers on a later read when the cold-cache fetch fails', async () => { + const instance = client({ datafile: undefined }); + dataFetch.mockResolvedValueOnce(new Response(null, { status: 503 })); + await expect(instance.evaluate('feature')).rejects.toThrow( + 'Failed to fetch data', + ); + mockDatafileResponse(TIMESTAMP, true); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'vercel', cacheStatus: 'MISS' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('does not finish initialization after shutdown while bundled data is loading', async () => { + const pending = + deferred>>(); + vi.mocked(readBundledDefinitions).mockReturnValueOnce(pending.promise); + const instance = client({ datafile: undefined }); + const initializing = instance.initialize(); + const outcome = expect(initializing).rejects.toThrow('Client is shut down'); + await vi.advanceTimersByTimeAsync(0); + await instance.shutdown(); + clients.delete(instance); + pending.resolve({ definitions: datafile(), state: 'ok' }); + + await outcome; + expect(transport).not.toHaveBeenCalled(); + }); + + it.each([ + HEADER, + 'flags-config-versions', + ])('initializes and refreshes using %s', async (headerName) => { + cleanupContext(); + cleanupContext = setRequestContext({ + [headerName]: `flags_${PROJECT_ID}=${TIMESTAMP}`, + }); + const instance = client(); + + const initial = await instance.evaluate('feature'); + expect(initial.value).toBe(false); + expect(initial.metrics).toMatchObject({ + mode: 'vercel', + cacheStatus: 'HIT', + }); + expect(dataFetch).not.toHaveBeenCalled(); + + cleanupContext(); + cleanupContext = setRequestContext({ + [headerName]: `flags_${PROJECT_ID}=${TIMESTAMP + 20_000}`, + }); + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 20_000, true)), + ); + + vi.setSystemTime(TIMESTAMP + 10_001); + const refreshed = await instance.evaluate('feature'); + expect(refreshed.value).toBe(true); + expect(refreshed.metrics).toMatchObject({ + mode: 'vercel', + source: 'remote', + cacheStatus: 'MISS', + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it.each([ + 'provided', + 'bundled', + ] as const)('uses fresh %s definitions without opening a stream or polling', async (origin) => { + setVersion(undefined); + const bundled = datafile(); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: bundled, + state: 'ok', + }); + const instance = client({ + datafile: origin === 'provided' ? bundled : undefined, + }); + await instance.initialize(); + expect(readBundledDefinitions).toHaveBeenCalledTimes( + origin === 'bundled' ? 1 : 0, + ); + expect(transport).not.toHaveBeenCalled(); + expect(await instance.getDatafile()).toEqual({ + ...bundled, + metrics: expect.objectContaining({ + mode: 'vercel', + source: origin === 'provided' ? 'in-memory' : 'embedded', + cacheStatus: 'STALE', + }), + }); + setVersion(TIMESTAMP); + + const result = await instance.evaluate('feature'); + + expect(result.value).toBe(false); + expect(result.metrics).toMatchObject({ + mode: 'vercel', + source: origin === 'provided' ? 'in-memory' : 'embedded', + cacheStatus: 'HIT', + connectionState: 'disconnected', + }); + await vi.advanceTimersByTimeAsync(60_000); + expect(dataFetch).not.toHaveBeenCalled(); + expect( + transport.mock.calls.every(([url]) => String(url).endsWith('/v1/ingest')), + ).toBe(true); + }); + + it.each([ + undefined, + 'flags_other=1700000000000', + `flags_${PROJECT_ID}=invalid`, + ])('keeps the configured offline fallback when the matching header is unavailable: %s', async (header) => { + cleanupContext(); + cleanupContext = setRequestContext(header ? { [HEADER]: header } : {}); + const instance = client({ stream: false, polling: false }); + + const result = await instance.evaluate('feature'); + + expect(result.value).toBe(false); + expect(result.metrics).toMatchObject({ + mode: 'offline', + cacheStatus: 'STALE', + }); + expect(dataFetch).not.toHaveBeenCalled(); + }); + + it.each([ + [true, false, 'vercel'], + [false, true, 'vercel'], + [false, false, 'offline'], + ] as const)('version headers with stream=%s and polling=%s use %s mode', async (stream, polling, mode) => { + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + const instance = client({ stream, polling }); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: mode === 'vercel', + metrics: { mode }, + }); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'vercel' ? 1 : 0); + }); + + it('does not enable runtime header refresh during a build', async () => { + setVersion(TIMESTAMP + 20_000); + const instance = client({ buildStep: true }); + + const result = await instance.evaluate('feature'); + + expect(result.value).toBe(false); + expect(result.metrics?.mode).toBe('build'); + expect(dataFetch).not.toHaveBeenCalled(); + }); + + it.each([ + 1, 10_000, + ])('serves stale data immediately at delta %i ms, then exposes the background update', async (delta) => { + setVersion(TIMESTAMP + delta); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + setVersion(TIMESTAMP); + await instance.evaluate('feature'); + setVersion(TIMESTAMP + delta); + + const first = await instance.evaluate('feature'); + expect(first.value).toBe(false); + expect(first.metrics).toMatchObject({ + mode: 'vercel', + cacheStatus: 'STALE', + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect((await instance.evaluate('feature')).value).toBe(false); + expect(dataFetch).toHaveBeenCalledTimes(1); + + pending.resolve(Response.json(datafile(TIMESTAMP + delta, true))); + await vi.advanceTimersByTimeAsync(0); + const second = await instance.evaluate('feature'); + + expect(second.value).toBe(true); + expect(second.metrics).toMatchObject({ + source: 'remote', + cacheStatus: 'HIT', + }); + expect((await instance.getDatafile()).configUpdatedAt).toBe( + TIMESTAMP + delta, + ); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('blocks for unknown freshness and shares one fetch across evaluate and bulkEvaluate', async () => { + setVersion(TIMESTAMP + 10_001); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + const settled = vi.fn(); + const single = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + const bulk = instance.bulkEvaluate([ + { key: 'feature', defaultValue: false }, + ]); + await vi.advanceTimersByTimeAsync(0); + + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(dataFetch).toHaveBeenCalledWith( + 'https://flags.vercel.com/v1/datafile', + { + headers: expect.objectContaining({ + Authorization: `Bearer ${SDK_KEY}`, + 'X-Vercel-Env': 'production', + }), + signal: expect.any(AbortSignal), + }, + ); + pending.resolve(Response.json(datafile(TIMESTAMP + 10_001, true))); + const [result, results] = await Promise.all([single, bulk]); + + for (const evaluation of [result, results.feature]) { + expect(evaluation?.value).toBe(true); + expect(evaluation?.metrics).toMatchObject({ + mode: 'vercel', + source: 'remote', + cacheStatus: 'MISS', + }); + } + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('rechecks new request versions instead of caching the first HIT forever', async () => { + const instance = client(); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + + setVersion(TIMESTAMP + 20_000); + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 20_000, true)), + ); + vi.setSystemTime(TIMESTAMP + 10_001); + const second = await instance.evaluate('feature'); + expect(second.value).toBe(true); + expect(second.metrics?.cacheStatus).toBe('MISS'); + + setVersion(TIMESTAMP + 40_000); + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 40_000, false)), + ); + vi.setSystemTime(TIMESTAMP + 20_002); + const third = await instance.evaluate('feature'); + expect(third.value).toBe(false); + expect(third.metrics?.cacheStatus).toBe('MISS'); + expect(dataFetch).toHaveBeenCalledTimes(2); + + setVersion(); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('does not make a fresh request wait on another requests blocking refresh', async () => { + const instance = client(); + await instance.initialize(); + setVersion(TIMESTAMP + 20_000); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const blocking = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(0); + + setVersion(TIMESTAMP); + const hitSettled = vi.fn(); + const hit = instance.evaluate('feature').then((result) => { + hitSettled(result); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + // Settle the transport even when the independence assertion fails. + const completedBeforeFetch = hitSettled.mock.calls.length; + pending.resolve(Response.json(datafile(TIMESTAMP + 20_000, true))); + const [blockingResult, hitResult] = await Promise.all([blocking, hit]); + + expect(completedBeforeFetch).toBe(1); + expect(hitResult.value).toBe(false); + expect(hitResult.metrics?.cacheStatus).toBe('HIT'); + expect(blockingResult.value).toBe(true); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('retries a failed blocking refresh instead of poisoning subsequent evaluations', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setVersion(TIMESTAMP + 20_000); + dataFetch.mockResolvedValueOnce( + new Response(null, { status: 503, statusText: 'Service Unavailable' }), + ); + const instance = client({ staleIfErrorMs: 0 }); + + const failed = await instance.evaluate('feature', false); + expect(failed.value).toBe(false); + expect(failed.reason).toBe('error'); + expect(failed.errorMessage).toContain('Service Unavailable'); + expect(errorSpy).not.toHaveBeenCalled(); + + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 20_000, true)), + ); + const recovered = await instance.evaluate('feature'); + expect(recovered.value).toBe(true); + expect(recovered.metrics?.cacheStatus).toBe('MISS'); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('contains background fetch errors and retries without losing cached data', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setVersion(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + setVersion(TIMESTAMP); + await instance.evaluate('feature'); + setVersion(TIMESTAMP + 1); + + expect((await instance.evaluate('feature')).value).toBe(false); + + const failure = new Error('Network unavailable'); + pending.reject(failure); + await vi.advanceTimersByTimeAsync(0); + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Header refresh failed:', + failure, + ); + dataFetch.mockResolvedValueOnce( + Response.json(datafile(TIMESTAMP + 1, true)), + ); + expect((await instance.evaluate('feature')).value).toBe(false); + await vi.advanceTimersByTimeAsync(0); + + expect((await instance.evaluate('feature')).value).toBe(true); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('aborts an in-flight header refresh on shutdown', async () => { + setVersion(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + setVersion(TIMESTAMP); + await instance.evaluate('feature'); + setVersion(TIMESTAMP + 1); + + await instance.evaluate('feature'); + const signal = dataFetch.mock.calls[0]?.[1]?.signal; + + await instance.shutdown(); + clients.delete(instance); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + await vi.advanceTimersByTimeAsync(0); + + expect(signal?.aborted).toBe(true); + }); + + it.each([ + undefined, + 100, + 20_000, + ])('honors staleWhileRevalidateMs=%s at the boundary and on expiry', async (staleWhileRevalidateMs) => { + const waitUntil = vi.fn(); + const instance = client({ staleWhileRevalidateMs, waitUntil }); + await instance.evaluate('feature'); + waitUntil.mockClear(); + + const windowMs = staleWhileRevalidateMs ?? 10_000; + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + setVersion(TIMESTAMP + 100_000); + vi.setSystemTime(TIMESTAMP + windowMs); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); + const lifetime = waitUntil.mock.calls[0]?.[0] as Promise; + const lifetimeSettled = vi.fn(); + void lifetime.then(lifetimeSettled); + + vi.setSystemTime(TIMESTAMP + windowMs + 1); + const settled = vi.fn(); + const blocking = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(lifetimeSettled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(datafile(TIMESTAMP + 100_000, true))); + expect((await blocking).metrics?.cacheStatus).toBe('MISS'); + await lifetime; + expect(lifetimeSettled).toHaveBeenCalledTimes(1); + }); + + it('disables stale serving with a zero window, even immediately after a HIT', async () => { + const instance = client({ staleWhileRevalidateMs: 0 }); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + expect(dataFetch).not.toHaveBeenCalled(); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it.each([ + -1, + NaN, + Infinity, + -Infinity, + ])('rejects invalid staleWhileRevalidateMs=%s', (staleWhileRevalidateMs) => { + expect(() => client({ staleWhileRevalidateMs })).toThrow( + 'staleWhileRevalidateMs must be a finite, non-negative number', + ); + }); + +}); From 9e3e05308adb4a664e93fdaa05fea09c93539609 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 09:48:45 +0200 Subject: [PATCH 04/41] test(flags-core): cover persisted freshness and header error recovery --- .../src/vercel-mode.black-box.test.ts | 528 ++++++++++++++++++ 1 file changed, 528 insertions(+) diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 1751d279b..2de618d23 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -632,4 +632,532 @@ describe('Vercel mode (black-box)', () => { ); }); + it.each([ + 'provided', + 'bundled', + ] as const)('blocks on the first invalidation of unknown-age %s data', async (origin) => { + const input = datafile(); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: input, + state: 'ok', + }); + const instance = client({ + datafile: origin === 'provided' ? input : undefined, + }); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + expect(await instance.getDatafile()).toEqual({ + ...datafile(TIMESTAMP + 1, true), + fetchedAt: TIMESTAMP, + metrics: expect.any(Object), + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it.each([ + 'provided', + 'bundled', + ] as const)('uses persisted %s fetchedAt until its original freshness expires', async (origin) => { + const input = Object.freeze({ + ...datafile(TIMESTAMP - 365 * 24 * 60 * 60 * 1_000), + fetchedAt: TIMESTAMP - 9_000, + }); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: input, + state: 'ok', + }); + const instance = client({ + datafile: origin === 'provided' ? input : undefined, + }); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + + // A recent fetch, not the config's age or a matching header, permits SWR. + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + vi.setSystemTime(TIMESTAMP + 1_000); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect((await instance.getDatafile()).fetchedAt).toBe(TIMESTAMP - 9_000); + + vi.setSystemTime(TIMESTAMP + 1_001); + const settled = vi.fn(); + const blocking = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(datafile(TIMESTAMP, true))); + expect(await blocking).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + expect(await instance.getDatafile()).toEqual({ + ...datafile(TIMESTAMP, true), + fetchedAt: TIMESTAMP + 1_001, + metrics: expect.any(Object), + }); + expect(input.fetchedAt).toBe(TIMESTAMP - 9_000); + expect(input).not.toHaveProperty('_origin'); + }); + + it('preserves serialized fetchedAt in another client without making old data fresh', async () => { + const first = client({ + datafile: undefined, + stream: false, + polling: false, + }); + mockDatafileResponse(TIMESTAMP, true); + const fetched = await first.getDatafile(); + expect(fetched.fetchedAt).toBe(TIMESTAMP); + expect(fetched).not.toHaveProperty('_fetchedAt'); + expect(fetched).not.toHaveProperty('_origin'); + const later = TIMESTAMP + 365 * 24 * 60 * 60 * 1_000; + vi.setSystemTime(later); + const second = client({ datafile: JSON.parse(JSON.stringify(fetched)) }); + expect((await second.getDatafile()).fetchedAt).toBe(TIMESTAMP); + + setVersion(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const settled = vi.fn(); + const blocking = second.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, false))); + expect(await blocking).toMatchObject({ + value: false, + metrics: { cacheStatus: 'MISS' }, + }); + expect((await second.getDatafile()).fetchedAt).toBe(later); + expect(fetched.fetchedAt).toBe(TIMESTAMP); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['older', TIMESTAMP - 1], + ['missing', undefined], + ['malformed', 'invalid'], + ['newer', TIMESTAMP + 1], + ] as const)('does not renew freshness for %s headers', async (_kind, version) => { + const instance = client(); + await instance.evaluate('feature'); + vi.setSystemTime(TIMESTAMP + 9_000); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + setVersion(version); + await instance.evaluate('feature'); + vi.setSystemTime(TIMESTAMP + 10_001); + setVersion(TIMESTAMP + 1); + const settled = vi.fn(); + const blocking = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + expect((await blocking).metrics?.cacheStatus).toBe('MISS'); + }); + + it('does not renew freshness from an older matching header after observing an invalidation', async () => { + const instance = client(); + await instance.evaluate('feature'); + vi.setSystemTime(TIMESTAMP + 9_000); + setVersion(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + + // An overlapping request still has the old header, but cannot undo invalidation. + setVersion(TIMESTAMP); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + vi.setSystemTime(TIMESTAMP + 10_001); + setVersion(TIMESTAMP + 1); + const settled = vi.fn(); + const blocking = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + expect(await blocking).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + }); + + it('uses the later of the matching-header and fetched timestamps', async () => { + const instance = client(); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'MISS', + ); + vi.setSystemTime(TIMESTAMP + 9_000); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + + // The matching header extends freshness beyond the original fetch time. + vi.setSystemTime(TIMESTAMP + 19_000); + setVersion(TIMESTAMP + 100_000); + mockDatafileResponse(TIMESTAMP + 2, false); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); + + // The accepted response renews fetched freshness beyond the confirmation. + vi.setSystemTime(TIMESTAMP + 29_000); + mockDatafileResponse(TIMESTAMP + 100_000, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).configUpdatedAt).toBe( + TIMESTAMP + 100_000, + ); + expect(dataFetch).toHaveBeenCalledTimes(3); + }); + + it.each([ + 'provided', + 'bundled', + ] as const)('does not share confirmation across clients using the same %s object', async (origin) => { + const input = datafile(); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: input, + state: 'ok', + }); + const options = { datafile: origin === 'provided' ? input : undefined }; + const first = client(options); + const second = client(options); + await first.evaluate('feature'); + setVersion(TIMESTAMP + 1); + + mockDatafileResponse(TIMESTAMP + 1, true); + expect((await second.evaluate('feature')).metrics?.cacheStatus).toBe( + 'MISS', + ); + mockDatafileResponse(TIMESTAMP + 1, true); + expect((await first.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + await vi.advanceTimersByTimeAsync(0); + expect(dataFetch).toHaveBeenCalledTimes(2); + expect(input).not.toHaveProperty('_lastSeen'); + }); + + it.each([ + 0, -1, + ])('ignores a background response with version delta %i without extending freshness', async (delta) => { + const instance = client(); + await instance.evaluate('feature'); + vi.setSystemTime(TIMESTAMP + 9_000); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + delta, true); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.getDatafile()).toEqual({ + ...datafile(), + metrics: expect.any(Object), + }); + + vi.setSystemTime(TIMESTAMP + 10_001); + mockDatafileResponse(TIMESTAMP + 1, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('does not extend freshness after a failed background response', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const instance = client(); + await instance.evaluate('feature'); + vi.setSystemTime(TIMESTAMP + 9_000); + setVersion(TIMESTAMP + 1); + dataFetch.mockResolvedValueOnce(new Response(null, { status: 503 })); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + await vi.advanceTimersByTimeAsync(0); + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Header refresh failed:', + expect.any(Error), + ); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); + vi.setSystemTime(TIMESTAMP + 10_001); + mockDatafileResponse(TIMESTAMP + 1, true); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'MISS', + ); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it.each([ + 0, -1, + ])('keeps the cache unchanged after a blocking response with version delta %i', async (delta) => { + const instance = client(); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + await instance.evaluate('feature'); + + vi.setSystemTime(TIMESTAMP + 10_001); + setVersion(TIMESTAMP + 2); + mockDatafileResponse(TIMESTAMP + 1 + delta, false); + // Both the blocking read and later snapshots use the cache version guard. + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + expect(await instance.getDatafile()).toEqual({ + ...datafile(TIMESTAMP + 1, true), + fetchedAt: TIMESTAMP, + metrics: expect.any(Object), + }); + + mockDatafileResponse(TIMESTAMP + 2, true); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'MISS', + ); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); + expect(dataFetch).toHaveBeenCalledTimes(3); + }); + + it('finishes the background refresh even when waitUntil registration throws', async () => { + const waitUntil = vi.fn(() => { + throw new Error('No request lifetime available'); + }); + const instance = client({ waitUntil }); + await instance.evaluate('feature'); + waitUntil.mockClear(); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'HIT' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('uses the default unlimited stale-if-error allowance after blocking failures', async () => { + setVersion(TIMESTAMP + 1); + const instance = client(); + dataFetch.mockRejectedValue(new Error('service unavailable')); + + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + vi.setSystemTime(TIMESTAMP + 365 * 24 * 60 * 60 * 1_000); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('shares the first-error deadline with snapshots and recovers after expiry', async () => { + const instance = client({ staleIfErrorMs: 1_000 }); + setVersion(TIMESTAMP + 1); + const firstError = new Error('first failure'); + dataFetch.mockRejectedValueOnce(firstError); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + + vi.setSystemTime(TIMESTAMP + 1_000); + dataFetch.mockRejectedValueOnce(new Error('second failure')); + expect((await instance.evaluate('feature')).value).toBe(false); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); + + vi.setSystemTime(TIMESTAMP + 1_001); + // Older/missing evidence must not reset the first-error deadline or fetch. + for (const version of [undefined, 'invalid', TIMESTAMP - 1, TIMESTAMP]) { + setVersion(version); + await expect(instance.evaluate('feature')).rejects.toBe(firstError); + await expect(instance.getDatafile()).rejects.toBe(firstError); + } + expect(dataFetch).toHaveBeenCalledTimes(2); + + setVersion(TIMESTAMP + 1); + dataFetch.mockRejectedValueOnce(new Error('third failure')); + await expect(instance.evaluate('feature')).rejects.toBe(firstError); + mockDatafileResponse(TIMESTAMP + 1, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); + expect(dataFetch).toHaveBeenCalledTimes(4); + }); + + it.each([ + -1, 0, 1, + ])('blocks after a background failure and applies recovery for response delta %i', async (delta) => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const instance = client({ + datafile: { ...datafile(), fetchedAt: TIMESTAMP }, + staleIfErrorMs: 0, + }); + setVersion(TIMESTAMP + 1); + const failure = new Error('background failure'); + dataFetch.mockRejectedValueOnce(failure); + expect((await instance.evaluate('feature')).value).toBe(false); + await vi.advanceTimersByTimeAsync(0); + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Header refresh failed:', + failure, + ); + await expect(instance.getDatafile()).rejects.toBe(failure); + + vi.setSystemTime(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const settled = vi.fn(); + const read = instance.evaluate('feature').finally(settled); + const outcome = + delta < 0 + ? expect(read).rejects.toBe(failure) + : expect(read).resolves.toMatchObject({ + value: delta > 0, + metrics: { cacheStatus: 'MISS' }, + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(2); + + pending.resolve(Response.json(datafile(TIMESTAMP + delta, true))); + await outcome; + if (delta < 0) { + await expect(instance.getDatafile()).rejects.toBe(failure); + return; + } + // A matching response confirms recovery without replacing or retagging data. + expect(await instance.getDatafile()).toMatchObject({ + configUpdatedAt: TIMESTAMP + delta, + fetchedAt: TIMESTAMP + delta, + }); + }); + + it.each([ + '', + 'flags_other=123', + `flags_${PROJECT_ID}=0`, + `flags_${PROJECT_ID}=-1`, + `flags_${PROJECT_ID}=NaN`, + `flags_${PROJECT_ID}=Infinity`, + `flags_${PROJECT_ID}=`, + ])('ignores unusable header %s without starting sources', async (header) => { + cleanupContext(); + cleanupContext = setRequestContext({ [HEADER]: header }); + const instance = client(); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE', mode: 'vercel' }, + }); + expect(transport).not.toHaveBeenCalled(); + }); + + it('prefers the Vercel header and parses spaced project entries with a legacy cached version', async () => { + cleanupContext(); + cleanupContext = setRequestContext({ + [HEADER]: ` flags_other=1 ; flags_${PROJECT_ID}=${TIMESTAMP} ; flags_other2=3 `, + 'flags-config-versions': `flags_${PROJECT_ID}=${TIMESTAMP + 1}`, + }); + const instance = client({ + datafile: { ...datafile(), configUpdatedAt: String(TIMESTAMP) }, + }); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'HIT' }, + }); + expect(dataFetch).not.toHaveBeenCalled(); + }); + + it('keeps cached definitions without a config version', async () => { + setVersion(TIMESTAMP + 1); + const instance = client({ + datafile: { ...datafile(), configUpdatedAt: undefined }, + }); + expect((await instance.evaluate('feature')).value).toBe(false); + expect(dataFetch).not.toHaveBeenCalled(); + }); + + it('rejects a blocking read on shutdown even if the transport ignores cancellation', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setVersion(TIMESTAMP + 1); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + const reading = instance.evaluate('feature'); + const outcome = expect(reading).rejects.toThrow(); + await vi.advanceTimersByTimeAsync(0); + const signal = dataFetch.mock.calls[0]?.[1]?.signal; + await instance.shutdown(); + clients.delete(instance); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + await outcome; + expect(signal?.aborted).toBe(true); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('reports vercel mode in config-read telemetry', async () => { + const instance = client(); + await instance.evaluate('feature'); + await instance.shutdown(); + clients.delete(instance); + + const events = transport.mock.calls + .filter(([url]) => String(url).endsWith('/v1/ingest')) + .flatMap(([, init]) => JSON.parse(String(init?.body))); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'FLAGS_CONFIG_READ', + payload: expect.objectContaining({ + mode: 'vercel', + configUpdatedAt: TIMESTAMP, + cacheAction: 'NONE', + }), + }), + ]), + ); + }); }); From 9d60b263cd544a45bfe39cd5408d161d51c4d972 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 10:19:53 +0200 Subject: [PATCH 05/41] feat(flags-core): accept stale-while-revalidate in seconds --- .changeset/header-driven-vercel-mode.md | 2 +- packages/vercel-flags-core/CLAUDE.md | 4 ++-- .../src/controller/normalized-options.ts | 19 ++++++++++--------- .../src/vercel-mode.black-box.test.ts | 18 +++++++++--------- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index d71c9635c..0bdf79080 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -2,4 +2,4 @@ "@vercel/flags-core": minor --- -Add a header-driven `vercel` client mode that uses timestamps from the `x-vercel-flags-config-versions` or `flags-config-versions` request header instead of streaming or polling. The `vercel` option defaults to `process.env.VERCEL === '1'` and can be explicitly enabled or disabled. Vercel mode loads provided or bundled definitions during initialization, uses cached data when no usable header is available, and fetches on the first read if the cache is empty. Disabling both streaming and polling preserves offline behavior. Reuse fresh definitions, refresh in the background for up to `staleWhileRevalidateMs` (10 seconds by default) since the cached configuration was last successfully fetched or confirmed by a matching request header at the highest observed version, and block for a refresh when that freshness expires or is unknown. Set `staleWhileRevalidateMs` to `0` to disable background stale serving. Refresh failures use the shared `staleIfErrorMs` allowance; set that to `0` to propagate failures immediately. Datafiles expose an optional `fetchedAt` timestamp that survives serialization and reuse. Bundled and provided definitions preserve valid timestamps; definitions without one start with unknown age. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. +Add a header-driven `vercel` client mode that uses timestamps from the `x-vercel-flags-config-versions` or `flags-config-versions` request header instead of streaming or polling. The `vercel` option defaults to `process.env.VERCEL === '1'` and can be explicitly enabled or disabled. Vercel mode loads provided or bundled definitions during initialization, uses cached data when no usable header is available, and fetches on the first read if the cache is empty. Disabling both streaming and polling preserves offline behavior. Reuse fresh definitions, refresh in the background for up to `staleWhileRevalidate` (10 seconds by default) since the cached configuration was last successfully fetched or confirmed by a matching request header at the highest observed version, and block for a refresh when that freshness expires or is unknown. Set `staleWhileRevalidate` to `0` to disable background stale serving. Refresh failures use the shared `staleIfError` allowance; set that to `0` to propagate failures immediately. Datafiles expose an optional `fetchedAt` timestamp that survives serialization and reuse. Bundled and provided definitions preserve valid timestamps; definitions without one start with unknown age. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 088ade5e6..596483015 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -101,7 +101,7 @@ type ControllerOptions = { stream?: boolean | { initTimeoutMs: number }; // default: true (3000ms) polling?: boolean | { intervalMs: number; initTimeoutMs: number }; // default: true (30s interval, 3s timeout) vercel?: boolean; // default: process.env.VERCEL === '1'; replaces stream/poll at runtime - staleWhileRevalidateMs?: number; // header refresh only; default: 10_000 + staleWhileRevalidate?: number; // Seconds of header refresh grace; default: 10 staleIfError?: number; // Seconds of fallback after update failure; default: Infinity buildStep?: boolean; // Override build step auto-detection metricEnvironment?: string; // Environment attached to ingested evaluation metrics @@ -131,7 +131,7 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu - Do not start stream/poll; the first read fetches if the cache is empty. - HeaderSource parses the request's project version and owns `highestObserved` and `lastSeen`. - A matching header confirms freshness only when no newer version has been observed. -- A newer header refreshes in the background within `staleWhileRevalidateMs` of the latest +- A newer header refreshes in the background within `staleWhileRevalidate` of the latest accepted fetch or matching header; unknown/expired freshness requires a blocking refresh. - Every returned entry passes through `DatafileCache.read()`. Refresh errors use its `staleIfErrorMs` allowance; expiry forces blocking recovery on the next newer-header read. diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index 288bc50f8..feb7cf38e 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -12,7 +12,7 @@ const DEFAULT_STREAM_INIT_TIMEOUT_MS = 3000; const DEFAULT_POLLING_INTERVAL_MS = 30_000; const MIN_POLLING_INTERVAL_MS = 30_000; const DEFAULT_POLLING_INIT_TIMEOUT_MS = 3_000; -const DEFAULT_STALE_WHILE_REVALIDATE_MS = 10_000; +const DEFAULT_STALE_WHILE_REVALIDATE = 10; /** * Configuration options for Controller @@ -57,10 +57,11 @@ export type ControllerOptions = { /** * How long header-driven reads may serve cached data while refreshing in the * background, measured from its last fetch or matching version header. - * Must be a finite, non-negative number. Set to 0 to always block on refresh. - * @default 10000 + * Accepts finite, non-negative seconds, including fractional seconds. + * Set to 0 to always block on refresh. + * @default 10 */ - staleWhileRevalidateMs?: number; + staleWhileRevalidate?: number; /** * How long runtime reads may use cached data after the first consecutive @@ -178,11 +179,11 @@ export function normalizeOptions( }; } - const staleWhileRevalidateMs = - options.staleWhileRevalidateMs ?? DEFAULT_STALE_WHILE_REVALIDATE_MS; - if (!Number.isFinite(staleWhileRevalidateMs) || staleWhileRevalidateMs < 0) { + const staleWhileRevalidate = + options.staleWhileRevalidate ?? DEFAULT_STALE_WHILE_REVALIDATE; + if (!Number.isFinite(staleWhileRevalidate) || staleWhileRevalidate < 0) { throw new Error( - '@vercel/flags-core: staleWhileRevalidateMs must be a finite, non-negative number.', + '@vercel/flags-core: staleWhileRevalidate must be a finite, non-negative number of seconds.', ); } @@ -192,7 +193,7 @@ export function normalizeOptions( stream, polling, vercel: options.vercel ?? process.env.VERCEL === '1', - staleWhileRevalidateMs, + staleWhileRevalidateMs: staleWhileRevalidate * 1000, staleIfErrorMs: staleIfError * 1000, buildStep, fetch: options.fetch ?? globalThis.fetch, diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 2de618d23..5c1ad7248 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -568,15 +568,15 @@ describe('Vercel mode (black-box)', () => { it.each([ undefined, - 100, - 20_000, - ])('honors staleWhileRevalidateMs=%s at the boundary and on expiry', async (staleWhileRevalidateMs) => { + 0.1, + 20, + ])('honors staleWhileRevalidate=%s at the boundary and on expiry', async (staleWhileRevalidate) => { const waitUntil = vi.fn(); - const instance = client({ staleWhileRevalidateMs, waitUntil }); + const instance = client({ staleWhileRevalidate, waitUntil }); await instance.evaluate('feature'); waitUntil.mockClear(); - const windowMs = staleWhileRevalidateMs ?? 10_000; + const windowMs = (staleWhileRevalidate ?? 10) * 1000; const pending = deferred(); dataFetch.mockReturnValueOnce(pending.promise); setVersion(TIMESTAMP + 100_000); @@ -606,7 +606,7 @@ describe('Vercel mode (black-box)', () => { }); it('disables stale serving with a zero window, even immediately after a HIT', async () => { - const instance = client({ staleWhileRevalidateMs: 0 }); + const instance = client({ staleWhileRevalidate: 0 }); expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( 'HIT', ); @@ -626,9 +626,9 @@ describe('Vercel mode (black-box)', () => { NaN, Infinity, -Infinity, - ])('rejects invalid staleWhileRevalidateMs=%s', (staleWhileRevalidateMs) => { - expect(() => client({ staleWhileRevalidateMs })).toThrow( - 'staleWhileRevalidateMs must be a finite, non-negative number', + ])('rejects invalid staleWhileRevalidate=%s', (staleWhileRevalidate) => { + expect(() => client({ staleWhileRevalidate })).toThrow( + 'staleWhileRevalidate must be a finite, non-negative number of seconds', ); }); From e19b948876332ce305824430f926b237ca069376 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 10:25:53 +0200 Subject: [PATCH 06/41] refactor(flags-core): drive cache reads with source callbacks --- .changeset/header-driven-vercel-mode.md | 6 +- packages/vercel-flags-core/CLAUDE.md | 18 +- packages/vercel-flags-core/README.md | 34 +++- .../src/controller/datafile-cache.ts | 123 ++++++++++-- .../src/controller/header-source.ts | 184 ++++-------------- .../vercel-flags-core/src/controller/index.ts | 43 ++-- .../src/vercel-mode.black-box.test.ts | 6 +- 7 files changed, 235 insertions(+), 179 deletions(-) diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index 0bdf79080..2c3975dac 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -2,4 +2,8 @@ "@vercel/flags-core": minor --- -Add a header-driven `vercel` client mode that uses timestamps from the `x-vercel-flags-config-versions` or `flags-config-versions` request header instead of streaming or polling. The `vercel` option defaults to `process.env.VERCEL === '1'` and can be explicitly enabled or disabled. Vercel mode loads provided or bundled definitions during initialization, uses cached data when no usable header is available, and fetches on the first read if the cache is empty. Disabling both streaming and polling preserves offline behavior. Reuse fresh definitions, refresh in the background for up to `staleWhileRevalidate` (10 seconds by default) since the cached configuration was last successfully fetched or confirmed by a matching request header at the highest observed version, and block for a refresh when that freshness expires or is unknown. Set `staleWhileRevalidate` to `0` to disable background stale serving. Refresh failures use the shared `staleIfError` allowance; set that to `0` to propagate failures immediately. Datafiles expose an optional `fetchedAt` timestamp that survives serialization and reuse. Bundled and provided definitions preserve valid timestamps; definitions without one start with unknown age. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown. +Add a header-driven `vercel` client mode, enabled by default when `VERCEL=1`. Initialization loads provided/bundled definitions; request versions trigger refreshes and an empty cache fetches on its first read. Explicit offline/build behavior is preserved. + +The controller supplies source freshness, staleness, and revalidation callbacks to the cache. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. Streaming/polling retain their existing schedules. + +Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 596483015..25808742a 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -131,8 +131,10 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu - Do not start stream/poll; the first read fetches if the cache is empty. - HeaderSource parses the request's project version and owns `highestObserved` and `lastSeen`. - A matching header confirms freshness only when no newer version has been observed. -- A newer header refreshes in the background within `staleWhileRevalidate` of the latest - accepted fetch or matching header; unknown/expired freshness requires a blocking refresh. +- The controller passes `isFresh`, `isStale`, and `revalidate` callbacks to `cache.resolve()`. + The cache selects cached/background/blocking behavior and shares refresh work. +- A newer header permits background refresh within `staleWhileRevalidate` seconds of the + latest accepted fetch or matching header; unknown/expired freshness blocks for refresh. - Every returned entry passes through `DatafileCache.read()`. Refresh errors use its `staleIfErrorMs` allowance; expiry forces blocking recovery on the next newer-header read. - Missing/malformed headers use cached data without fetching, subject to stale-if-error. @@ -324,16 +326,24 @@ The DatafileCache rejects incoming data (from stream or poll) if its `configUpda ### Cache read policy -`DatafileCache.read()` is the only full-entry read. The cache is configured once +Every served entry passes through `DatafileCache.read()`, including `resolve(policy)` results. The cache is configured once with the internal `staleIfErrorMs`, normalized from the public `staleIfError` option in seconds. Evaluations and `getDatafile()` share the same serving -boundary. `hasData`, `revision`, and `metadata` expose coordination metadata even after expiry, +boundary. `hasData` and `revision` expose coordination metadata even after expiry, so retained data is not replaced by fallback and stream reconnects can still send `X-Revision`. `seed()` never clears failure. Accepted source updates or valid version/revision confirmations clear it; repeated errors/disconnects do not renew the first-error deadline. Stream opening/pings and initialization timeout alone are not recovery/failure evidence respectively. +`cache.resolve(policy)` receives mode-specific `isFresh`, `isStale`, and optional +`revalidate` functions. It owns background/blocking decisions, `waitUntil`, shared +revalidation, and cancellation on clear. HeaderSource supplies small version/age +checks and a fetch callback; it does not read the cache. Header confirmations are +forwarded through controller event wiring. Stream/poll modes omit on-read revalidation +and retain their existing schedules. New public time windows use seconds; internal +normalized durations and `fetchedAt` use milliseconds. + ### Evaluation Safety - Regex comparators (`REGEX`, `NOT_REGEX`) limit input string length to 10,000 characters to prevent ReDoS diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index e51e4eb3a..9389bcda3 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -33,10 +33,36 @@ export default app; Outside Vercel, pass an SDK key explicitly: `createClient(process.env.FLAGS)`. -## Cached stream and polling reads +## Header-driven reads on Vercel + +When `VERCEL=1`, the client defaults to `vercel: true`. Initialization loads provided +or bundled definitions without starting a stream or polling. Request version headers +indicate when cached definitions need refreshing; reads without a usable header keep +cached definitions, fetching only when the cache is empty. + +```ts +const client = createClient(process.env.FLAGS!, { + vercel: true, + staleWhileRevalidate: 10, // Seconds of background-refresh grace. + staleIfError: 60, // Seconds of cached fallback after a refresh failure. +}); +``` + +`staleWhileRevalidate` defaults to 10 seconds and accepts finite, nonnegative values, +including fractions. `0` makes refreshes block. The window starts at the latest +accepted fetch or matching request-header confirmation. Bundled/provided definitions +preserve their original `fetchedAt`; unknown or expired freshness requires a blocking +refresh when a newer request version arrives. Refresh failures use `staleIfError`. +A newer-header read attempts blocking recovery after that failure allowance expires. + +`getDatafile()` remains a snapshot read: it applies stale-if-error but does not inspect +headers. Use `vercel: false` to select the existing stream/poll behavior. Disabling both +stream and polling still selects offline mode, and builds retain their existing loading. + +## Cached reads after errors `staleIfError` controls how many seconds evaluations and `getDatafile()` may use -cached flag definitions after a stream/poll failure or stream disconnect: +cached flag definitions after a stream/poll/header-refresh failure or stream disconnect: ```ts const client = createClient(process.env.FLAGS!, { @@ -66,8 +92,8 @@ retained for recovery, including its revision for stream reconnection. A clean stream close or ping timeout records `stream: disconnected` if no earlier failure exists. `getFallbackDatafile()` remains an independent bundled-data export. -There is no age-based expiry while the source is healthy, and reads do not trigger -an extra refresh after expiry. Build/offline behavior, source scheduling, retries, +Stream/poll modes have no age-based expiry while the source is healthy, and reads +do not trigger an extra refresh after expiry. Build/offline behavior, source scheduling, retries, timeouts, metrics categories, and logging are unchanged. An initialization timeout alone does not start the allowance. Existing startup limitations remain: when initial polling times out, no recurring interval is started, even if that in-flight diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 37b32580b..f6840f2ab 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -1,4 +1,4 @@ -import type { DatafileInput } from '../types'; +import type { DatafileInput, Metrics, WaitUntil } from '../types'; import { type DataOrigin, type TaggedData, tagData } from './tagged-data'; type Confirmation = Pick< @@ -8,6 +8,18 @@ type Confirmation = Pick< export type CacheMetadata = Confirmation & Pick; +type Revalidate = (signal: AbortSignal) => Promise; +type CacheResult = [TaggedData, Metrics['cacheStatus']]; + +export type CacheReadPolicy = { + /** Undefined adds no freshness evidence and keeps cached-read behavior. */ + isFresh: (data: CacheMetadata) => boolean | undefined; + /** Whether stale data may be served while revalidation runs. */ + isStale: (data: CacheMetadata) => boolean; + /** Omit for modes whose stream/poll loop already maintains the cache. */ + revalidate?: Revalidate; +}; + /** * Parses a configUpdatedAt value (number or string) into a numeric timestamp. * Returns undefined if the value is missing or cannot be parsed. @@ -21,12 +33,18 @@ function parseConfigUpdatedAt(value: unknown): number | undefined { return undefined; } -/** Storage and failure-relative read policy; callers provide source evidence. */ +/** Storage, serving policy, and revalidation driven by source callbacks. */ export class DatafileCache { private data: TaggedData | undefined; private failure: { error: Error; startedAt: number } | undefined; - constructor(private readonly staleIfErrorMs = Infinity) {} + private abortController = new AbortController(); + private revalidation: Promise | undefined; + + constructor( + private readonly staleIfErrorMs = Infinity, + private readonly waitUntil: WaitUntil = () => {}, + ) {} get hasData(): boolean { return this.data !== undefined; @@ -38,7 +56,7 @@ export class DatafileCache { } /** Freshness checks can inspect retained metadata even after serving expires. */ - get metadata(): CacheMetadata | undefined { + private get metadata(): CacheMetadata | undefined { if (!this.data) return undefined; const { projectId, environment, configUpdatedAt, revision, fetchedAt } = this.data; @@ -107,21 +125,104 @@ export class DatafileCache { this.failure ??= { error, startedAt: Date.now() }; } + private canServe(): boolean { + if (!this.failure || this.staleIfErrorMs === Infinity) return true; + return ( + this.staleIfErrorMs > 0 && + Date.now() - this.failure.startedAt <= this.staleIfErrorMs + ); + } + + /** The serving boundary for both snapshot and policy-driven reads. */ read(): TaggedData | undefined { if (!this.data) return undefined; + if (!this.canServe()) throw this.failure!.error; + return this.data; + } - if (!this.failure || this.staleIfErrorMs === Infinity) return this.data; + async resolve(policy: CacheReadPolicy): Promise { + const metadata = this.metadata; + if (metadata) { + const fresh = policy.isFresh(metadata); + if (fresh !== false || !policy.revalidate) { + return [this.read()!, fresh ? 'HIT' : 'STALE']; + } + if (policy.isStale(metadata) && this.canServe()) { + const stale = this.read()!; + this.revalidateInBackground(policy.revalidate); + return [stale, 'STALE']; + } + } - const withinAllowance = - this.staleIfErrorMs > 0 && - Date.now() - this.failure.startedAt <= this.staleIfErrorMs; - if (!withinAllowance) { - throw this.failure.error; + if (!policy.revalidate) return; + const { promise, signal } = this.startRevalidation(policy.revalidate); + try { + await promise; + signal.throwIfAborted(); + } catch (error) { + if (signal.aborted) throw error; + const stale = this.read(); + if (!stale) throw error; + return [stale, 'STALE']; + } + + // A cold fetch discovers the project; assess the original request's header. + if (!metadata && this.metadata) policy.isFresh(this.metadata); + const data = this.read(); + if (!data) + throw new Error( + '@vercel/flags-core: Revalidation returned no definitions', + ); + return [data, 'MISS']; + } + + private startRevalidation(revalidate: Revalidate) { + const { signal } = this.abortController; + if (this.revalidation) return { promise: this.revalidation, signal }; + + const promise = Promise.resolve() + .then(() => { + signal.throwIfAborted(); + return revalidate(signal); + }) + .then(() => signal.throwIfAborted()) + .catch((error) => { + if (!signal.aborted) { + this.fail( + error instanceof Error + ? error + : new Error('Unknown revalidation error'), + ); + } + throw error; + }) + .finally(() => { + // An old, aborted operation must not clear a newer one. + if (this.abortController.signal === signal) + this.revalidation = undefined; + }); + this.revalidation = promise; + return { promise, signal }; + } + + private revalidateInBackground(revalidate: Revalidate): void { + const { promise, signal } = this.startRevalidation(revalidate); + const background = promise.catch((error) => { + if (!signal.aborted) { + console.error('@vercel/flags-core: Revalidation failed:', error); + } + }); + try { + this.waitUntil(background); + } catch { + // Registration is best-effort; the handled refresh continues regardless. } - return this.data; } clear(): void { + this.abortController.abort(); + this.abortController = new AbortController(); + this.revalidation = undefined; this.data = undefined; } } diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index b1be3d5c9..4868af7d0 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -1,67 +1,35 @@ -import type { BundledDefinitions, DatafileInput, Metrics } from '../types'; +import type { DatafileInput } from '../types'; import { getRequestContext } from '../utils/request-context'; -import type { CacheMetadata, DatafileCache } from './datafile-cache'; +import type { CacheMetadata, CacheReadPolicy } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import type { NormalizedOptions } from './normalized-options'; -import type { TaggedData } from './tagged-data'; import { TypedEmitter } from './typed-emitter'; export type HeaderSourceEvents = { data: (data: DatafileInput) => void; - error: (error: Error) => void; + confirmed: (data: CacheMetadata) => void; }; -/** - * Manages a lazy pulling of flag data from the flags service using the version header. - */ +/** Request version evidence and fetching; the cache decides how to serve reads. */ export class HeaderSource extends TypedEmitter { - private options: NormalizedOptions; - private abortController: AbortController | undefined; - private promise: Promise | undefined; private highestObserved = 0; private lastSeen: { version: number; at: number } | undefined; - constructor(options: NormalizedOptions) { + constructor(private readonly options: NormalizedOptions) { super(); - - this.options = options; } - private fetchDatafile(): Promise { - // Share only the transport work, not request-specific freshness decisions. - if (this.promise) return this.promise; - - const abortController = new AbortController(); - this.abortController = abortController; - this.promise = fetchDatafile({ - ...this.options, - signal: abortController.signal, - }) - .then((data) => { - // A transport may finish after stop() even if it ignores cancellation. - abortController.signal.throwIfAborted(); - this.emit('data', data); - return data; - }) - .catch((error) => { - if (!abortController.signal.aborted) this.emit('error', error); - throw error; - }) - .finally(() => { - // An older, aborted fetch must not clear a newer request's work. - if (this.abortController === abortController) { - this.promise = undefined; - this.abortController = undefined; - } - }); - - return this.promise; + /** Capture this request's header before any cold-cache fetch awaits. */ + getFreshnessCheck(): CacheReadPolicy['isFresh'] { + const { headers } = getRequestContext(); + const header = + headers?.['x-vercel-flags-config-versions'] ?? + headers?.['flags-config-versions']; + return (data) => this.isFresh(data, header); } private getUpdatedAtHeader(projectId: string, header: string | undefined) { - if (!header) { - return; - } + if (!header) return; const prefix = `flags_${projectId}=`; const value = header @@ -70,113 +38,49 @@ export class HeaderSource extends TypedEmitter { .find((part) => part.startsWith(prefix)) ?.slice(prefix.length); const timestamp = Number(value); - return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : undefined; } - private async refresh( - cache: DatafileCache, - ): Promise<[TaggedData, Metrics['cacheStatus']]> { - const pending = this.fetchDatafile(); - const signal = this.abortController!.signal; - try { - await pending; - signal.throwIfAborted(); - } catch (error) { - if (signal.aborted) throw error; - // The controller recorded the failure; the cache decides whether to serve it. - const stale = cache.read(); - if (!stale) throw error; - return [stale, 'STALE']; - } - return [cache.read()!, 'MISS']; - } - - private revalidate(): void { - const pending = this.fetchDatafile(); - const signal = this.abortController?.signal; - const background = pending.catch((error) => { - if (!signal?.aborted) { - console.error('@vercel/flags-core: Header refresh failed:', error); - } - }); + private isFresh( + data: CacheMetadata, + header: string | undefined, + ): boolean | undefined { + const version = this.getUpdatedAtHeader(data.projectId, header); + if (!version) return; - try { - this.options.waitUntil(background); - } catch { - // Registration is best-effort; the handled refresh continues regardless. + const currentVersion = Number(data.configUpdatedAt); + this.highestObserved = Math.max(this.highestObserved, version); + // An older matching request cannot undo a newer request's invalidation. + if (version === currentVersion && version === this.highestObserved) { + this.lastSeen = { version, at: Date.now() }; + this.emit('confirmed', data); } - } - private async resolveData( - cache: DatafileCache, - current: CacheMetadata, - updatedAtHeader: number | undefined, - ): Promise<[TaggedData, Metrics['cacheStatus']] | undefined> { - if (!current.configUpdatedAt || !updatedAtHeader) return; - - const currentUpdatedAt = Number(current.configUpdatedAt); - if (updatedAtHeader <= currentUpdatedAt) { - return [cache.read()!, 'HIT']; - } + if (!data.configUpdatedAt) return; + return version <= currentVersion; + } + /** Whether this version is still inside its background-refresh window. */ + isStale = (data: CacheMetadata): boolean => { const freshAt = Math.max( - current.fetchedAt ?? -Infinity, - this.lastSeen?.version === currentUpdatedAt + data.fetchedAt ?? -Infinity, + this.lastSeen?.version === Number(data.configUpdatedAt) ? this.lastSeen.at : -Infinity, ); const { staleWhileRevalidateMs } = this.options; - if ( + return ( staleWhileRevalidateMs > 0 && Date.now() - freshAt <= staleWhileRevalidateMs - ) { - let stale: TaggedData | undefined; - try { - stale = cache.read(); - } catch { - // Expired stale-if-error requires a blocking recovery attempt below. - } - if (stale) { - this.revalidate(); - return [stale, 'STALE']; - } - } - - return this.refresh(cache); - } - - private observe(version: number, currentVersion: number): boolean { - this.highestObserved = Math.max(this.highestObserved, version); - // Once invalidated, an older matching header cannot renew freshness. - if (version !== currentVersion || version !== this.highestObserved) - return false; - this.lastSeen = { version, at: Date.now() }; - return true; - } - - async read( - cache: DatafileCache, - ): Promise<[TaggedData, Metrics['cacheStatus']] | undefined> { - // Capture the request header before a cold fetch discovers its project. - const { headers } = getRequestContext(); - const header = - headers?.['x-vercel-flags-config-versions'] ?? - headers?.['flags-config-versions']; - const current = cache.metadata; - const fetched = current ? undefined : await this.refresh(cache); - const metadata = current ?? cache.metadata!; - const updatedAtHeader = this.getUpdatedAtHeader(metadata.projectId, header); - if ( - updatedAtHeader && - this.observe(updatedAtHeader, Number(metadata.configUpdatedAt)) - ) { - cache.tryConfirm(metadata); - } + ); + }; - if (fetched) return fetched; - return this.resolveData(cache, metadata, updatedAtHeader); - } + revalidate = async (signal: AbortSignal): Promise => { + const data = await fetchDatafile({ ...this.options, signal }); + // Transports can finish after cancellation; never publish that response. + signal.throwIfAborted(); + this.emit('data', data); + }; isAvailable(): boolean { // Explicit offline mode disables header-driven refreshes too. @@ -186,13 +90,7 @@ export class HeaderSource extends TypedEmitter { ); } - /** - * Abort the current header-driven fetch and discard its pending work. - */ stop(): void { - this.abortController?.abort(); - this.abortController = undefined; - this.promise = undefined; this.lastSeen = undefined; this.highestObserved = 0; } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 2c6bb898b..9f89250f9 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -11,7 +11,11 @@ import type { TrackEvaluationOptions } from '../utils/usage/flags-evaluation'; import { UsageTracker } from '../utils/usage-tracker'; import { unauthorizedMessage } from './auth'; import { BundledSource } from './bundled-source'; -import { DatafileCache } from './datafile-cache'; +import { + type CacheMetadata, + type CacheReadPolicy, + DatafileCache, +} from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { HeaderSource } from './header-source'; import { @@ -116,7 +120,10 @@ export class Controller implements ControllerInterface { constructor(options: ControllerOptions) { this.options = normalizeOptions(options); - this.cache = new DatafileCache(this.options.staleIfErrorMs); + this.cache = new DatafileCache( + this.options.staleIfErrorMs, + this.options.waitUntil, + ); // Create source modules this.streamSource = new StreamSource( @@ -178,6 +185,9 @@ export class Controller implements ControllerInterface { private onHeaderData = (data: DatafileInput) => { this.cache.updateFromSource(data, 'fetched'); }; + private onHeaderConfirmed = (data: CacheMetadata) => { + this.cache.tryConfirm(data); + }; private onPollError = (error: Error) => { this.noteUnauthorized(error); this.cache.fail(error); @@ -197,7 +207,7 @@ export class Controller implements ControllerInterface { this.pollingSource.on('data', this.onPollData); this.pollingSource.on('error', this.onPollError); this.headerSource.on('data', this.onHeaderData); - this.headerSource.on('error', this.onSourceError); + this.headerSource.on('confirmed', this.onHeaderConfirmed); } private unwireSourceEvents(): void { @@ -209,7 +219,7 @@ export class Controller implements ControllerInterface { this.pollingSource.off('data', this.onPollData); this.pollingSource.off('error', this.onPollError); this.headerSource.off('data', this.onHeaderData); - this.headerSource.off('error', this.onSourceError); + this.headerSource.off('confirmed', this.onHeaderConfirmed); } // --------------------------------------------------------------------------- @@ -458,18 +468,25 @@ export class Controller implements ControllerInterface { return this.resolveDataForBuildStep(); } - if (this.state === 'vercel') { - const result = await this.headerSource.read(this.cache); - if (result) return result; - } + const result = await this.cache.resolve(this.getCacheReadPolicy()); + if (result) return result; + return this.resolveDataWithFallbacks(); + } - const data = this.cache.read(); - if (data) { - const cacheStatus = this.isConnected ? 'HIT' : 'STALE'; - return [data, cacheStatus]; + private getCacheReadPolicy(): CacheReadPolicy { + if (this.state === 'vercel') { + return { + isFresh: this.headerSource.getFreshnessCheck(), + isStale: this.headerSource.isStale, + revalidate: this.headerSource.revalidate, + }; } - return this.resolveDataWithFallbacks(); + // Stream/poll maintain their existing schedules; reads do not trigger I/O. + return { + isFresh: () => this.isConnected, + isStale: () => true, + }; } // --------------------------------------------------------------------------- diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 5c1ad7248..31b839a6f 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -533,7 +533,7 @@ describe('Vercel mode (black-box)', () => { pending.reject(failure); await vi.advanceTimersByTimeAsync(0); expect(errorSpy).toHaveBeenCalledExactlyOnceWith( - '@vercel/flags-core: Header refresh failed:', + '@vercel/flags-core: Revalidation failed:', failure, ); dataFetch.mockResolvedValueOnce( @@ -909,7 +909,7 @@ describe('Vercel mode (black-box)', () => { ); await vi.advanceTimersByTimeAsync(0); expect(errorSpy).toHaveBeenCalledExactlyOnceWith( - '@vercel/flags-core: Header refresh failed:', + '@vercel/flags-core: Revalidation failed:', expect.any(Error), ); expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); @@ -1041,7 +1041,7 @@ describe('Vercel mode (black-box)', () => { expect((await instance.evaluate('feature')).value).toBe(false); await vi.advanceTimersByTimeAsync(0); expect(errorSpy).toHaveBeenCalledExactlyOnceWith( - '@vercel/flags-core: Header refresh failed:', + '@vercel/flags-core: Revalidation failed:', failure, ); await expect(instance.getDatafile()).rejects.toBe(failure); From 4fa0e2ee56ed021029f4ec9bd9838f15af73e69f Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 10:25:54 +0200 Subject: [PATCH 07/41] test(flags-core): cover cache callbacks and request isolation --- .../controller/datafile-cache-policy.test.ts | 198 ++++++++++++++++++ .../src/vercel-mode.black-box.test.ts | 38 ++++ 2 files changed, 236 insertions(+) create mode 100644 packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts diff --git a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts new file mode 100644 index 000000000..d391ea9cc --- /dev/null +++ b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DatafileInput } from '../types'; +import { type CacheReadPolicy, DatafileCache } from './datafile-cache'; +import { tagData } from './tagged-data'; + +function data(configUpdatedAt = 1): DatafileInput { + return { + projectId: 'prj_policy', + environment: 'production', + definitions: {}, + configUpdatedAt, + }; +} + +function deferred() { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +let errorSpy: ReturnType; +let warnSpy: ReturnType; +beforeEach(() => { + vi.useFakeTimers({ now: 1_000 }); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(() => { + try { + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + vi.restoreAllMocks(); + vi.useRealTimers(); + } +}); + +describe('cache read callbacks', () => { + it.each([ + true, + undefined, + ])('serves a %s assessment without revalidating or clearing a failure', async (fresh) => { + const cache = new DatafileCache(0); + const original = tagData(data(), 'provided'); + cache.seed(original); + const policy = { + isFresh: vi.fn(() => fresh), + isStale: vi.fn(() => true), + revalidate: vi.fn(async () => {}), + }; + + expect(await cache.resolve(policy)).toEqual([ + original, + fresh ? 'HIT' : 'STALE', + ]); + expect(policy.isFresh).toHaveBeenCalledExactlyOnceWith({ + projectId: 'prj_policy', + environment: 'production', + configUpdatedAt: 1, + revision: undefined, + fetchedAt: undefined, + }); + const failure = new Error('outage'); + cache.fail(failure); + await expect(cache.resolve(policy)).rejects.toBe(failure); + expect(policy.isStale).not.toHaveBeenCalled(); + expect(policy.revalidate).not.toHaveBeenCalled(); + }); + + it('shares a background refresh with a later blocking read', async () => { + const waitUntil = vi.fn(); + const cache = new DatafileCache(Infinity, waitUntil); + const original = tagData(data(), 'provided'); + cache.seed(original); + const pending = deferred(); + const revalidate = vi.fn(async () => { + await pending.promise; + cache.updateFromSource(data(2), 'fetched'); + }); + const policy = { isFresh: () => false, isStale: () => true, revalidate }; + expect(await cache.resolve(policy)).toEqual([original, 'STALE']); + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); + const settled = vi.fn(); + const blocking = cache + .resolve({ ...policy, isStale: () => false }) + .then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(revalidate).toHaveBeenCalledExactlyOnceWith(expect.any(AbortSignal)); + + pending.resolve(); + expect(await blocking).toEqual([cache.read(), 'MISS']); + await expect(waitUntil.mock.calls[0]?.[0]).resolves.toBeUndefined(); + expect(cache.read()?.configUpdatedAt).toBe(2); + }); + + it('uses the failure deadline even when the callback still permits stale serving', async () => { + const waitUntil = vi.fn(); + const cache = new DatafileCache(0, waitUntil); + cache.seed(tagData(data(), 'provided')); + const failure = new Error('refresh failed'); + const revalidate = vi + .fn>() + .mockRejectedValueOnce(failure); + const policy = { isFresh: () => false, isStale: () => true, revalidate }; + + expect((await cache.resolve(policy))?.[1]).toBe('STALE'); + await waitUntil.mock.calls[0]?.[0]; + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Revalidation failed:', + failure, + ); + errorSpy.mockClear(); + expect(() => cache.read()).toThrow(failure); + + revalidate.mockImplementationOnce(async () => + cache.updateFromSource(data(2), 'fetched'), + ); + expect((await cache.resolve(policy))?.[1]).toBe('MISS'); + expect(cache.read()?.configUpdatedAt).toBe(2); + expect(revalidate).toHaveBeenCalledTimes(2); + expect(waitUntil).toHaveBeenCalledTimes(1); + }); + + it('contains synchronous revalidation failures and permits a later retry', async () => { + const cache = new DatafileCache(); + const failure = new Error('synchronous failure'); + const revalidate = vi.fn>(() => { + throw failure; + }); + const policy = { isFresh: () => false, isStale: () => false, revalidate }; + await expect(cache.resolve(policy)).rejects.toBe(failure); + revalidate.mockImplementationOnce(async () => + cache.updateFromSource(data(), 'fetched'), + ); + expect((await cache.resolve(policy))?.[1]).toBe('MISS'); + expect(revalidate).toHaveBeenCalledTimes(2); + }); + + it('cancels queued revalidation without invoking the callback', async () => { + const cache = new DatafileCache(); + const revalidate = vi.fn(async () => {}); + const reading = cache.resolve({ + isFresh: () => false, + isStale: () => false, + revalidate, + }); + const outcome = expect(reading).rejects.toThrow(); + cache.clear(); + await outcome; + expect(revalidate).not.toHaveBeenCalled(); + }); + + it('does not let cancelled work fail or clear a newer revalidation', async () => { + const cache = new DatafileCache(0); + cache.seed(tagData(data(), 'provided')); + const oldPending = deferred(); + const nextPending = deferred(); + const revalidate = vi + .fn>() + .mockImplementationOnce(() => oldPending.promise) + .mockImplementationOnce(async (signal) => { + await nextPending.promise; + signal.throwIfAborted(); + cache.updateFromSource(data(2), 'fetched'); + }); + const policy = { isFresh: () => false, isStale: () => false, revalidate }; + const oldRead = cache.resolve(policy); + const cancelled = expect(oldRead).rejects.toThrow('cancelled transport'); + await vi.advanceTimersByTimeAsync(0); + const oldSignal = revalidate.mock.calls[0]?.[0]; + cache.clear(); + cache.seed(tagData(data(), 'provided')); + const nextRead = cache.resolve(policy); + await vi.advanceTimersByTimeAsync(0); + oldPending.reject(new Error('cancelled transport')); + await cancelled; + expect(oldSignal?.aborted).toBe(true); + expect(cache.read()?.configUpdatedAt).toBe(1); + + const sharedRead = cache.resolve(policy); + await vi.advanceTimersByTimeAsync(0); + expect(revalidate).toHaveBeenCalledTimes(2); + nextPending.resolve(); + expect(await Promise.all([nextRead, sharedRead])).toEqual([ + [cache.read(), 'MISS'], + [cache.read(), 'MISS'], + ]); + expect(cache.read()?.configUpdatedAt).toBe(2); + }); +}); diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 31b839a6f..31f2ca811 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -196,6 +196,44 @@ describe('Vercel mode (black-box)', () => { expect(dataFetch).toHaveBeenCalledTimes(cache === 'empty' ? 1 : 0); }); + it('retains the original cold request header when the request context changes during a fetch', async () => { + const firstFetch = deferred(); + dataFetch.mockReturnValueOnce(firstFetch.promise); + const instance = client({ datafile: undefined }); + const firstRead = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(0); + setVersion(TIMESTAMP + 1); + firstFetch.resolve(Response.json(datafile())); + expect((await firstRead).metrics?.cacheStatus).toBe('MISS'); + + // The unrelated context above must not prevent a matching request confirming freshness. + vi.setSystemTime(TIMESTAMP + 9_000); + setVersion(TIMESTAMP); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', + ); + vi.setSystemTime(TIMESTAMP + 10_001); + setVersion(TIMESTAMP + 1); + const nextFetch = deferred(); + dataFetch.mockReturnValueOnce(nextFetch.promise); + const settled = vi.fn(); + const reading = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + const servedBeforeRefresh = settled.mock.calls.length; + nextFetch.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + expect(await reading).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + expect(servedBeforeRefresh).toBe(1); + await vi.advanceTimersByTimeAsync(0); + expect(dataFetch).toHaveBeenCalledTimes(2); + expect((await instance.evaluate('feature')).value).toBe(true); + }); + it('recovers on a later read when the cold-cache fetch fails', async () => { const instance = client({ datafile: undefined }); dataFetch.mockResolvedValueOnce(new Response(null, { status: 503 })); From 53cb35f813ba593d1a42bbaead5d91c3c942f9c4 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 11:25:24 +0200 Subject: [PATCH 08/41] refactor(flags-core): preserve bundled initialization flow --- .../vercel-flags-core/src/controller/index.ts | 9 ++------- .../src/vercel-mode.black-box.test.ts | 16 ---------------- 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 9f89250f9..eb292b6a2 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -278,17 +278,12 @@ export class Controller implements ControllerInterface { // send the revision to the stream and potentially get a lightweight // "primed" response instead of a full datafile. if (!this.cache.hasData) { - this.transition('initializing:fallback'); - let bundled: DatafileInput | undefined; try { - bundled = await this.bundledSource.tryLoad(); + const bundled = await this.bundledSource.tryLoad(); + if (bundled) this.cache.seed(tagData(bundled, 'bundled')); } catch { // Bundled definitions not available — proceed without revision } - if (this.state === 'shutdown') { - throw new Error('@vercel/flags-core: Client is shut down'); - } - if (bundled) this.cache.seed(tagData(bundled, 'bundled')); } if (this.headerSource.isAvailable()) { diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 31f2ca811..ea49e130a 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -249,22 +249,6 @@ describe('Vercel mode (black-box)', () => { expect(dataFetch).toHaveBeenCalledTimes(2); }); - it('does not finish initialization after shutdown while bundled data is loading', async () => { - const pending = - deferred>>(); - vi.mocked(readBundledDefinitions).mockReturnValueOnce(pending.promise); - const instance = client({ datafile: undefined }); - const initializing = instance.initialize(); - const outcome = expect(initializing).rejects.toThrow('Client is shut down'); - await vi.advanceTimersByTimeAsync(0); - await instance.shutdown(); - clients.delete(instance); - pending.resolve({ definitions: datafile(), state: 'ok' }); - - await outcome; - expect(transport).not.toHaveBeenCalled(); - }); - it.each([ HEADER, 'flags-config-versions', From b83293a651545de5bc8e63bc79113f14782fe212 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 11:26:09 +0200 Subject: [PATCH 09/41] refactor(flags-core): own freshness age in the cache --- .../controller/datafile-cache-policy.test.ts | 177 ++++++++++++++---- .../src/controller/datafile-cache.test.ts | 172 ++++++++++++++++- .../src/controller/datafile-cache.ts | 101 ++++++---- .../src/controller/header-source.ts | 52 +++-- .../vercel-flags-core/src/controller/index.ts | 10 +- .../src/vercel-mode.black-box.test.ts | 11 +- 6 files changed, 408 insertions(+), 115 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts index d391ea9cc..3a1e8f2ee 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DatafileInput } from '../types'; -import { type CacheReadPolicy, DatafileCache } from './datafile-cache'; +import { + type CacheReadPolicy, + DatafileCache, + Freshness, +} from './datafile-cache'; import { tagData } from './tagged-data'; function data(configUpdatedAt = 1): DatafileInput { @@ -40,35 +44,133 @@ afterEach(() => { }); describe('cache read callbacks', () => { + it.each( + Object.values(Freshness), + )('serves %s without fetching when fetch is omitted, subject to SIE', async (status) => { + const cache = new DatafileCache(0); + const original = tagData(data(), 'provided'); + cache.seed(original); + const policy = { getStatus: vi.fn(() => status) }; + expect(await cache.resolve(policy)).toEqual([ + original, + status === Freshness.Fresh ? 'HIT' : 'STALE', + ]); + const error = new Error('outage'); + cache.fail(error); + await expect(cache.resolve(policy)).rejects.toBe(error); + expect(policy.getStatus).toHaveBeenCalledTimes(2); + }); + + it('returns undefined without assessing an empty cache when fetch is omitted', async () => { + const cache = new DatafileCache(); + const getStatus = vi.fn(() => Freshness.Fresh); + expect(await cache.resolve({ getStatus })).toBeUndefined(); + expect(getStatus).not.toHaveBeenCalled(); + }); + it.each([ - true, - undefined, - ])('serves a %s assessment without revalidating or clearing a failure', async (fresh) => { + Freshness.Fresh, + Freshness.Unknown, + ])('serves a %s assessment without fetching or clearing a failure', async (status) => { const cache = new DatafileCache(0); const original = tagData(data(), 'provided'); cache.seed(original); const policy = { - isFresh: vi.fn(() => fresh), - isStale: vi.fn(() => true), - revalidate: vi.fn(async () => {}), + getStatus: vi.fn(() => status), + fetch: vi.fn(async () => {}), }; expect(await cache.resolve(policy)).toEqual([ original, - fresh ? 'HIT' : 'STALE', + status === Freshness.Fresh ? 'HIT' : 'STALE', ]); - expect(policy.isFresh).toHaveBeenCalledExactlyOnceWith({ + expect(policy.getStatus).toHaveBeenCalledExactlyOnceWith({ projectId: 'prj_policy', environment: 'production', configUpdatedAt: 1, revision: undefined, - fetchedAt: undefined, + ageMs: Infinity, }); const failure = new Error('outage'); cache.fail(failure); await expect(cache.resolve(policy)).rejects.toBe(failure); - expect(policy.isStale).not.toHaveBeenCalled(); - expect(policy.revalidate).not.toHaveBeenCalled(); + expect(policy.getStatus).toHaveBeenCalledTimes(2); + expect(policy.fetch).not.toHaveBeenCalled(); + }); + + it('blocks expired reads even when no failure exists', async () => { + const waitUntil = vi.fn(); + const cache = new DatafileCache(Infinity, waitUntil); + cache.seed(tagData(data(), 'provided')); + const pending = deferred(); + const fetch = vi.fn(async () => { + await pending.promise; + cache.updateFromSource(data(2), 'fetched'); + }); + const settled = vi.fn(); + const reading = cache + .resolve({ getStatus: () => Freshness.Expired, fetch }) + .then(settled); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledExactlyOnceWith(expect.any(AbortSignal)); + expect(waitUntil).not.toHaveBeenCalled(); + pending.resolve(); + await reading; + expect(settled).toHaveBeenCalledExactlyOnceWith([cache.read(), 'MISS']); + expect(cache.read()?.configUpdatedAt).toBe(2); + }); + + it('keeps the first fetch failure and its inclusive deadline across later attempts', async () => { + const cache = new DatafileCache(100); + const original = tagData(data(), 'provided'); + cache.seed(original); + const firstError = new Error('first outage'); + const fetch = vi + .fn>() + .mockRejectedValueOnce(firstError) + .mockRejectedValue(new Error('later outage')); + const policy = { getStatus: () => Freshness.Expired, fetch }; + expect(await cache.resolve(policy)).toEqual([original, 'STALE']); + vi.setSystemTime(1_100); + expect(await cache.resolve(policy)).toEqual([original, 'STALE']); + vi.setSystemTime(1_101); + await expect(cache.resolve(policy)).rejects.toBe(firstError); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('normalizes non-Error failures retained by the cache', async () => { + const cache = new DatafileCache(0); + cache.seed(tagData(data(), 'provided')); + const fetch = vi.fn().mockRejectedValue('transport failed'); + await expect( + cache.resolve({ getStatus: () => Freshness.Expired, fetch }), + ).rejects.toThrow('Unknown fetch error'); + expect(() => cache.read()).toThrow('Unknown fetch error'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('keeps background fetching handled when waitUntil registration throws', async () => { + const waitUntil = vi.fn<(promise: Promise) => void>(() => { + throw new Error('registration failed'); + }); + const cache = new DatafileCache(0, waitUntil); + const original = tagData(data(), 'provided'); + cache.seed(original); + const failure = new Error('fetch failed'); + const fetch = vi.fn().mockRejectedValue(failure); + expect( + await cache.resolve({ getStatus: () => Freshness.Stale, fetch }), + ).toEqual([original, 'STALE']); + await waitUntil.mock.calls[0]?.[0]; + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); + expect(fetch).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Revalidation failed:', + failure, + ); + errorSpy.mockClear(); + expect(() => cache.read()).toThrow(failure); }); it('shares a background refresh with a later blocking read', async () => { @@ -77,23 +179,23 @@ describe('cache read callbacks', () => { const original = tagData(data(), 'provided'); cache.seed(original); const pending = deferred(); - const revalidate = vi.fn(async () => { + const fetch = vi.fn(async () => { await pending.promise; cache.updateFromSource(data(2), 'fetched'); }); - const policy = { isFresh: () => false, isStale: () => true, revalidate }; + const policy = { getStatus: () => Freshness.Stale, fetch }; expect(await cache.resolve(policy)).toEqual([original, 'STALE']); expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); const settled = vi.fn(); const blocking = cache - .resolve({ ...policy, isStale: () => false }) + .resolve({ ...policy, getStatus: () => Freshness.Expired }) .then((result) => { settled(); return result; }); await vi.advanceTimersByTimeAsync(0); expect(settled).not.toHaveBeenCalled(); - expect(revalidate).toHaveBeenCalledExactlyOnceWith(expect.any(AbortSignal)); + expect(fetch).toHaveBeenCalledExactlyOnceWith(expect.any(AbortSignal)); pending.resolve(); expect(await blocking).toEqual([cache.read(), 'MISS']); @@ -106,10 +208,10 @@ describe('cache read callbacks', () => { const cache = new DatafileCache(0, waitUntil); cache.seed(tagData(data(), 'provided')); const failure = new Error('refresh failed'); - const revalidate = vi - .fn>() + const fetch = vi + .fn>() .mockRejectedValueOnce(failure); - const policy = { isFresh: () => false, isStale: () => true, revalidate }; + const policy = { getStatus: () => Freshness.Stale, fetch }; expect((await cache.resolve(policy))?.[1]).toBe('STALE'); await waitUntil.mock.calls[0]?.[0]; @@ -120,62 +222,61 @@ describe('cache read callbacks', () => { errorSpy.mockClear(); expect(() => cache.read()).toThrow(failure); - revalidate.mockImplementationOnce(async () => + fetch.mockImplementationOnce(async () => cache.updateFromSource(data(2), 'fetched'), ); expect((await cache.resolve(policy))?.[1]).toBe('MISS'); expect(cache.read()?.configUpdatedAt).toBe(2); - expect(revalidate).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledTimes(2); expect(waitUntil).toHaveBeenCalledTimes(1); }); - it('contains synchronous revalidation failures and permits a later retry', async () => { + it('contains synchronous fetch failures and permits a later retry', async () => { const cache = new DatafileCache(); const failure = new Error('synchronous failure'); - const revalidate = vi.fn>(() => { + const fetch = vi.fn>(() => { throw failure; }); - const policy = { isFresh: () => false, isStale: () => false, revalidate }; + const policy = { getStatus: () => Freshness.Expired, fetch }; await expect(cache.resolve(policy)).rejects.toBe(failure); - revalidate.mockImplementationOnce(async () => + fetch.mockImplementationOnce(async () => cache.updateFromSource(data(), 'fetched'), ); expect((await cache.resolve(policy))?.[1]).toBe('MISS'); - expect(revalidate).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledTimes(2); }); - it('cancels queued revalidation without invoking the callback', async () => { + it('cancels queued fetch without invoking the callback', async () => { const cache = new DatafileCache(); - const revalidate = vi.fn(async () => {}); + const fetch = vi.fn(async () => {}); const reading = cache.resolve({ - isFresh: () => false, - isStale: () => false, - revalidate, + getStatus: () => Freshness.Expired, + fetch, }); const outcome = expect(reading).rejects.toThrow(); cache.clear(); await outcome; - expect(revalidate).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); }); - it('does not let cancelled work fail or clear a newer revalidation', async () => { + it('does not let cancelled work fail or clear a newer fetch', async () => { const cache = new DatafileCache(0); cache.seed(tagData(data(), 'provided')); const oldPending = deferred(); const nextPending = deferred(); - const revalidate = vi - .fn>() + const fetch = vi + .fn>() .mockImplementationOnce(() => oldPending.promise) .mockImplementationOnce(async (signal) => { await nextPending.promise; signal.throwIfAborted(); cache.updateFromSource(data(2), 'fetched'); }); - const policy = { isFresh: () => false, isStale: () => false, revalidate }; + const policy = { getStatus: () => Freshness.Expired, fetch }; const oldRead = cache.resolve(policy); const cancelled = expect(oldRead).rejects.toThrow('cancelled transport'); await vi.advanceTimersByTimeAsync(0); - const oldSignal = revalidate.mock.calls[0]?.[0]; + const oldSignal = fetch.mock.calls[0]?.[0]; cache.clear(); cache.seed(tagData(data(), 'provided')); const nextRead = cache.resolve(policy); @@ -187,7 +288,7 @@ describe('cache read callbacks', () => { const sharedRead = cache.resolve(policy); await vi.advanceTimersByTimeAsync(0); - expect(revalidate).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledTimes(2); nextPending.resolve(); expect(await Promise.all([nextRead, sharedRead])).toEqual([ [cache.read(), 'MISS'], diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache.test.ts index c85d2b386..a4f8a6edc 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.test.ts @@ -38,6 +38,151 @@ afterEach(() => { }); describe('DatafileCache', () => { + describe('freshness age', () => { + it.each([ + undefined, + 500, + ])('resets age without changing stored fetchedAt %s or data', (fetchedAt) => { + const cache = new DatafileCache(); + const original = Object.freeze({ ...data('bundled'), fetchedAt }); + cache.seed(original); + vi.setSystemTime(2_000); + cache.resetAge(); + expect(cache.ageMs).toBe(0); + expect(cache.read()).toBe(original); + expect(original.fetchedAt).toBe(fetchedAt); + expect(original._origin).toBe('bundled'); + vi.setSystemTime(2_100); + expect(cache.ageMs).toBe(100); + }); + + it.each([ + 0, 100, + ])('preserves the first failure and deadline when resetting age with SIE %s, including after expiry', (staleIfErrorMs) => { + const cache = new DatafileCache(staleIfErrorMs); + const original = Object.freeze({ ...data(), fetchedAt: 500 }); + cache.seed(original); + const firstError = new Error('first outage'); + cache.fail(firstError); + vi.setSystemTime(1_050); + cache.resetAge(); + expect(cache.ageMs).toBe(0); + if (staleIfErrorMs === 0) { + expect(() => cache.read()).toThrow(firstError); + } else { + expect(cache.read()).toBe(original); + } + + cache.fail(new Error('later outage')); + vi.setSystemTime(1_100); + cache.resetAge(); + expect(cache.ageMs).toBe(0); + if (staleIfErrorMs === 0) { + expect(() => cache.read()).toThrow(firstError); + } else { + expect(cache.read()).toBe(original); + } + + vi.setSystemTime(1_101); + expect(() => cache.read()).toThrow(firstError); + cache.resetAge(); + expect(cache.ageMs).toBe(0); + expect(() => cache.read()).toThrow(firstError); + expect(original.fetchedAt).toBe(500); + vi.setSystemTime(1_200); + expect(cache.ageMs).toBe(99); + expect(() => cache.read()).toThrow(firstError); + }); + + it('does not establish age while empty or make a later unknown-age seed fresh', () => { + const cache = new DatafileCache(); + cache.resetAge(); + expect(cache.ageMs).toBe(Infinity); + expect(cache.read()).toBeUndefined(); + vi.setSystemTime(1_100); + const original = Object.freeze(data()); + cache.seed(original); + expect(cache.ageMs).toBe(Infinity); + expect(cache.read()).toBe(original); + }); + + it.each([ + 0, 500, 1_000, 2_000, + ])('restores age from persisted fetchedAt %s without changing storage', (fetchedAt) => { + const cache = new DatafileCache(); + const original = Object.freeze({ ...data(), fetchedAt }); + cache.seed(original); + expect(cache.ageMs).toBe(Math.max(0, 1_000 - fetchedAt)); + expect(cache.read()).toBe(original); + + vi.setSystemTime(3_000); + expect(cache.ageMs).toBe(3_000 - fetchedAt); + expect(original.fetchedAt).toBe(fetchedAt); + }); + + it.each([ + undefined, + -1, + NaN, + Infinity, + -Infinity, + '500', + null, + ])('treats invalid or missing fetchedAt %s as unknown age', (fetchedAt) => { + const cache = new DatafileCache(); + cache.updateFromSource(response(), 'fetched'); + expect(cache.ageMs).toBe(0); + const original = Object.freeze({ ...data(), fetchedAt }) as TaggedData; + cache.seed(original); + expect(cache.ageMs).toBe(Infinity); + vi.setSystemTime(2_000); + expect(cache.ageMs).toBe(Infinity); + expect(cache.read()).toBe(original); + expect(original.fetchedAt).toBe(fetchedAt); + }); + + it.each([ + 'configUpdatedAt', + 'revision', + ] as const)('resets age on valid %s confirmation while retaining fetchedAt and origin', (version) => { + const cache = new DatafileCache(); + const original = Object.freeze({ + ...data('bundled'), + revision: 42, + fetchedAt: 500, + }); + cache.seed(original); + vi.setSystemTime(2_000); + expect(cache.ageMs).toBe(1_500); + expect(cache.tryConfirm(original, version)).toBe(true); + expect(cache.ageMs).toBe(0); + expect(cache.read()).toBe(original); + expect(original.fetchedAt).toBe(500); + expect(original._origin).toBe('bundled'); + vi.setSystemTime(2_100); + expect(cache.ageMs).toBe(100); + }); + + it('clears storage and age without clearing the first failure', () => { + const cache = new DatafileCache(100); + cache.updateFromSource(response(), 'fetched'); + const error = new Error('first outage'); + cache.fail(error); + vi.setSystemTime(1_050); + expect(cache.ageMs).toBe(50); + cache.clear(); + expect(cache.ageMs).toBe(Infinity); + expect(cache.read()).toBeUndefined(); + expect(cache.tryConfirm(response())).toBe(false); + expect(cache.ageMs).toBe(Infinity); + cache.seed(Object.freeze({ ...data(), fetchedAt: 1_050 })); + expect(cache.ageMs).toBe(0); + vi.setSystemTime(1_101); + expect(cache.ageMs).toBe(51); + expect(() => cache.read()).toThrow(error); + }); + }); + it.each([ 0, 100, @@ -48,6 +193,7 @@ describe('DatafileCache', () => { }); const cache = new DatafileCache(staleIfErrorMs); expect(cache.hasData).toBe(false); + expect(cache.ageMs).toBe(Infinity); expect(cache.revision).toBeUndefined(); expect(cache.read()).toBeUndefined(); @@ -230,18 +376,20 @@ describe('DatafileCache', () => { Partial, ][])('rejects %s without changing storage or the failure deadline', (_, overrides) => { const cache = new DatafileCache(100); - const original = data(); + const original = Object.freeze({ ...data(), fetchedAt: 500 }); cache.seed(original); const error = new Error('first outage'); cache.fail(error); vi.setSystemTime(1_050); expect(cache.tryConfirm(response(overrides))).toBe(false); + expect(cache.ageMs).toBe(550); expect(cache.read()).toBe(original); vi.setSystemTime(1_100); expect(cache.read()).toBe(original); vi.setSystemTime(1_101); expect(() => cache.read()).toThrow(error); + expect(cache.ageMs).toBe(601); }); it.each([ @@ -262,6 +410,7 @@ describe('DatafileCache', () => { expect(cache.tryConfirm(response())).toBe(false); expect(cache.tryConfirm(response({ configUpdatedAt }))).toBe(false); expect(cache.tryConfirm(original)).toBe(false); + expect(cache.ageMs).toBe(Infinity); expect(cache.read()).toBe(original); vi.setSystemTime(1_101); expect(() => cache.read()).toThrow(error); @@ -337,6 +486,7 @@ describe('DatafileCache', () => { expect(cache.tryConfirm(incoming)).toBe(false); expect(() => cache.read()).toThrow(firstError); expect(cache.tryConfirm(incoming, 'revision')).toBe(true); + expect(cache.ageMs).toBe(0); expect(cache.read()).toBe(original); expect(original._origin).toBe('bundled'); expect(incoming).toEqual({ @@ -368,7 +518,11 @@ describe('DatafileCache', () => { ['negative infinite revision', { revision: -Infinity }], ])('rejects %s without replacing data or changing the failure deadline', (_, overrides) => { const cache = new DatafileCache(100); - const original = Object.freeze({ ...data('bundled'), revision: 42 }); + const original = Object.freeze({ + ...data('bundled'), + revision: 42, + fetchedAt: 500, + }); cache.seed(original); // Network payloads can contain malformed revisions despite the static type. const incoming = Object.freeze({ @@ -383,6 +537,7 @@ describe('DatafileCache', () => { vi.setSystemTime(1_050); expect(cache.tryConfirm(incoming, 'revision')).toBe(false); + expect(cache.ageMs).toBe(550); expect(cache.read()).toBe(original); vi.setSystemTime(1_100); expect(cache.read()).toBe(original); @@ -390,9 +545,12 @@ describe('DatafileCache', () => { expect(() => cache.read()).toThrow(error); expect(cache.revision).toBe(42); expect(cache.tryConfirm(incoming, 'revision')).toBe(false); + expect(cache.ageMs).toBe(601); expect(() => cache.read()).toThrow(error); expect(cache.tryConfirm(original, 'revision')).toBe(true); + expect(cache.ageMs).toBe(0); + expect(original.fetchedAt).toBe(500); expect(cache.read()).toBe(original); }); @@ -419,6 +577,7 @@ describe('DatafileCache', () => { false, ); expect(cache.tryConfirm(original, 'revision')).toBe(false); + expect(cache.ageMs).toBe(Infinity); expect(cache.read()).toBe(original); vi.setSystemTime(1_101); expect(cache.tryConfirm(original, 'revision')).toBe(false); @@ -431,6 +590,7 @@ describe('DatafileCache', () => { it.each([ 'poll', 'stream', + 'fetched', ] as const)('accepts the first %s response and clears a failure recorded while empty', (origin) => { const cache = new DatafileCache(0); cache.fail(new Error('failed before data arrived')); @@ -439,6 +599,7 @@ describe('DatafileCache', () => { const snapshot = { ...incoming }; expect(cache.updateFromSource(incoming, origin)).toBeUndefined(); + expect(cache.ageMs).toBe(0); expect(cache.read()).not.toBe(incoming); expect(cache.read()).toEqual({ ...snapshot, @@ -480,6 +641,7 @@ describe('DatafileCache', () => { const snapshot = { ...incoming }; expect(cache.updateFromSource(incoming, 'poll')).toBeUndefined(); + expect(cache.ageMs).toBe(0); const accepted = cache.read(); expect(accepted).not.toBe(incoming); expect(accepted).toEqual({ @@ -493,6 +655,7 @@ describe('DatafileCache', () => { const nextError = new Error('second outage'); cache.fail(nextError); vi.setSystemTime(2_100); + expect(cache.ageMs).toBe(100); expect(cache.read()).toBe(accepted); vi.setSystemTime(2_101); expect(() => cache.read()).toThrow(nextError); @@ -529,6 +692,7 @@ describe('DatafileCache', () => { const original = Object.freeze({ ...data('bundled'), configUpdatedAt: current, + fetchedAt: 500, }); cache.seed(original); const incoming = Object.freeze(response({ configUpdatedAt: next })); @@ -539,6 +703,8 @@ describe('DatafileCache', () => { expect(() => cache.read()).toThrow(error); expect(cache.updateFromSource(incoming, 'poll')).toBeUndefined(); + expect(cache.ageMs).toBe(0); + expect(original.fetchedAt).toBe(500); expect(cache.read()).toBe(original); expect(original._origin).toBe('bundled'); expect(incoming).toEqual(snapshot); @@ -671,6 +837,7 @@ describe('DatafileCache', () => { vi.setSystemTime(1_050); cache.updateFromSource(oldResponse, 'stream'); + expect(cache.ageMs).toBe(50); if (staleIfErrorMs === 0) { expect(() => cache.read()).toThrow(error); } else { @@ -686,6 +853,7 @@ describe('DatafileCache', () => { vi.setSystemTime(1_101); expect(() => cache.read()).toThrow(error); expect(cache.tryConfirm(replacement)).toBe(true); + expect(cache.ageMs).toBe(0); expect(cache.read()).toBe(accepted); }); }); diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index f6840f2ab..d5ea96369 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -6,18 +6,23 @@ type Confirmation = Pick< 'configUpdatedAt' | 'revision' | 'projectId' | 'environment' >; -export type CacheMetadata = Confirmation & Pick; +export type CacheMetadata = Confirmation & { ageMs: number }; -type Revalidate = (signal: AbortSignal) => Promise; +export enum Freshness { + Fresh = 'fresh', + Stale = 'stale', + Expired = 'expired', + Unknown = 'unknown', +} + +type Fetch = (signal: AbortSignal) => Promise; type CacheResult = [TaggedData, Metrics['cacheStatus']]; export type CacheReadPolicy = { - /** Undefined adds no freshness evidence and keeps cached-read behavior. */ - isFresh: (data: CacheMetadata) => boolean | undefined; - /** Whether stale data may be served while revalidation runs. */ - isStale: (data: CacheMetadata) => boolean; + /** Unknown adds no freshness evidence and keeps cached-read behavior. */ + getStatus: (data: CacheMetadata) => Freshness; /** Omit for modes whose stream/poll loop already maintains the cache. */ - revalidate?: Revalidate; + fetch?: Fetch; }; /** @@ -33,13 +38,14 @@ function parseConfigUpdatedAt(value: unknown): number | undefined { return undefined; } -/** Storage, serving policy, and revalidation driven by source callbacks. */ +/** Storage, serving policy, and fetching driven by source callbacks. */ export class DatafileCache { private data: TaggedData | undefined; + private freshAt: number | undefined; private failure: { error: Error; startedAt: number } | undefined; private abortController = new AbortController(); - private revalidation: Promise | undefined; + private fetching: Promise | undefined; constructor( private readonly staleIfErrorMs = Infinity, @@ -55,23 +61,47 @@ export class DatafileCache { return this.data?.revision; } + /** Time since the latest freshness evidence, if known. */ + get ageMs(): number { + return this.freshAt === undefined + ? Infinity + : Math.max(0, Date.now() - this.freshAt); + } + + /** Records freshness evidence without confirming recovery from a failure. */ + resetAge(): void { + if (this.data) this.freshAt = Date.now(); + } + /** Freshness checks can inspect retained metadata even after serving expires. */ private get metadata(): CacheMetadata | undefined { if (!this.data) return undefined; - const { projectId, environment, configUpdatedAt, revision, fetchedAt } = - this.data; - return { projectId, environment, configUpdatedAt, revision, fetchedAt }; + const { projectId, environment, configUpdatedAt, revision } = this.data; + return { + projectId, + environment, + configUpdatedAt, + revision, + ageMs: this.ageMs, + }; } /** Stores initial or fallback data without confirming recovery from a failure. */ seed(data: TaggedData): void { this.data = data; + this.freshAt = + typeof data.fetchedAt === 'number' && + Number.isFinite(data.fetchedAt) && + data.fetchedAt >= 0 + ? data.fetchedAt + : undefined; } /** Accepts a source update or confirms the current version without replacing it. */ updateFromSource(incoming: DatafileInput, origin: DataOrigin): void { if (this.isNewerData(incoming)) { this.data = tagData(incoming, origin); + this.resetAge(); this.failure = undefined; return; } @@ -103,6 +133,7 @@ export class DatafileCache { return false; } + this.resetAge(); this.failure = undefined; return true; } @@ -143,19 +174,23 @@ export class DatafileCache { async resolve(policy: CacheReadPolicy): Promise { const metadata = this.metadata; if (metadata) { - const fresh = policy.isFresh(metadata); - if (fresh !== false || !policy.revalidate) { - return [this.read()!, fresh ? 'HIT' : 'STALE']; + const status = policy.getStatus(metadata); + if ( + status === Freshness.Fresh || + status === Freshness.Unknown || + !policy.fetch + ) { + return [this.read()!, status === Freshness.Fresh ? 'HIT' : 'STALE']; } - if (policy.isStale(metadata) && this.canServe()) { + if (status === Freshness.Stale && this.canServe()) { const stale = this.read()!; - this.revalidateInBackground(policy.revalidate); + this.fetchInBackground(policy.fetch); return [stale, 'STALE']; } } - if (!policy.revalidate) return; - const { promise, signal } = this.startRevalidation(policy.revalidate); + if (!policy.fetch) return; + const { promise, signal } = this.startFetch(policy.fetch); try { await promise; signal.throwIfAborted(); @@ -167,46 +202,41 @@ export class DatafileCache { } // A cold fetch discovers the project; assess the original request's header. - if (!metadata && this.metadata) policy.isFresh(this.metadata); + if (!metadata && this.metadata) policy.getStatus(this.metadata); const data = this.read(); if (!data) - throw new Error( - '@vercel/flags-core: Revalidation returned no definitions', - ); + throw new Error('@vercel/flags-core: Fetch returned no definitions'); return [data, 'MISS']; } - private startRevalidation(revalidate: Revalidate) { + private startFetch(fetch: Fetch) { const { signal } = this.abortController; - if (this.revalidation) return { promise: this.revalidation, signal }; + if (this.fetching) return { promise: this.fetching, signal }; const promise = Promise.resolve() .then(() => { signal.throwIfAborted(); - return revalidate(signal); + return fetch(signal); }) .then(() => signal.throwIfAborted()) .catch((error) => { if (!signal.aborted) { this.fail( - error instanceof Error - ? error - : new Error('Unknown revalidation error'), + error instanceof Error ? error : new Error('Unknown fetch error'), ); } throw error; }) .finally(() => { // An old, aborted operation must not clear a newer one. - if (this.abortController.signal === signal) - this.revalidation = undefined; + if (this.abortController.signal === signal) this.fetching = undefined; }); - this.revalidation = promise; + this.fetching = promise; return { promise, signal }; } - private revalidateInBackground(revalidate: Revalidate): void { - const { promise, signal } = this.startRevalidation(revalidate); + private fetchInBackground(fetch: Fetch): void { + const { promise, signal } = this.startFetch(fetch); const background = promise.catch((error) => { if (!signal.aborted) { console.error('@vercel/flags-core: Revalidation failed:', error); @@ -222,7 +252,8 @@ export class DatafileCache { clear(): void { this.abortController.abort(); this.abortController = new AbortController(); - this.revalidation = undefined; + this.fetching = undefined; this.data = undefined; + this.freshAt = undefined; } } diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 4868af7d0..78d29b013 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -1,6 +1,10 @@ import type { DatafileInput } from '../types'; import { getRequestContext } from '../utils/request-context'; -import type { CacheMetadata, CacheReadPolicy } from './datafile-cache'; +import { + type CacheMetadata, + type CacheReadPolicy, + Freshness, +} from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import type { NormalizedOptions } from './normalized-options'; import { TypedEmitter } from './typed-emitter'; @@ -13,19 +17,18 @@ export type HeaderSourceEvents = { /** Request version evidence and fetching; the cache decides how to serve reads. */ export class HeaderSource extends TypedEmitter { private highestObserved = 0; - private lastSeen: { version: number; at: number } | undefined; constructor(private readonly options: NormalizedOptions) { super(); } /** Capture this request's header before any cold-cache fetch awaits. */ - getFreshnessCheck(): CacheReadPolicy['isFresh'] { + getStatusCheck(): CacheReadPolicy['getStatus'] { const { headers } = getRequestContext(); const header = headers?.['x-vercel-flags-config-versions'] ?? headers?.['flags-config-versions']; - return (data) => this.isFresh(data, header); + return (data) => this.getStatus(data, header); } private getUpdatedAtHeader(projectId: string, header: string | undefined) { @@ -41,41 +44,29 @@ export class HeaderSource extends TypedEmitter { return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : undefined; } - private isFresh( + private getStatus( data: CacheMetadata, header: string | undefined, - ): boolean | undefined { - const version = this.getUpdatedAtHeader(data.projectId, header); - if (!version) return; + ): Freshness { + const headerTs = this.getUpdatedAtHeader(data.projectId, header); + if (headerTs === undefined) return Freshness.Unknown; - const currentVersion = Number(data.configUpdatedAt); - this.highestObserved = Math.max(this.highestObserved, version); + const currentTs = Number(data.configUpdatedAt); + this.highestObserved = Math.max(this.highestObserved, headerTs); + if (!Number.isFinite(currentTs) || currentTs <= 0) return Freshness.Unknown; // An older matching request cannot undo a newer request's invalidation. - if (version === currentVersion && version === this.highestObserved) { - this.lastSeen = { version, at: Date.now() }; + if (headerTs === currentTs && headerTs === this.highestObserved) { this.emit('confirmed', data); } - if (!data.configUpdatedAt) return; - return version <= currentVersion; - } - - /** Whether this version is still inside its background-refresh window. */ - isStale = (data: CacheMetadata): boolean => { - const freshAt = Math.max( - data.fetchedAt ?? -Infinity, - this.lastSeen?.version === Number(data.configUpdatedAt) - ? this.lastSeen.at - : -Infinity, - ); + if (headerTs <= currentTs) return Freshness.Fresh; const { staleWhileRevalidateMs } = this.options; - return ( - staleWhileRevalidateMs > 0 && - Date.now() - freshAt <= staleWhileRevalidateMs - ); - }; + return staleWhileRevalidateMs > 0 && data.ageMs <= staleWhileRevalidateMs + ? Freshness.Stale + : Freshness.Expired; + } - revalidate = async (signal: AbortSignal): Promise => { + fetch = async (signal: AbortSignal): Promise => { const data = await fetchDatafile({ ...this.options, signal }); // Transports can finish after cancellation; never publish that response. signal.throwIfAborted(); @@ -91,7 +82,6 @@ export class HeaderSource extends TypedEmitter { } stop(): void { - this.lastSeen = undefined; this.highestObserved = 0; } } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index eb292b6a2..bd84e3f98 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -15,6 +15,7 @@ import { type CacheMetadata, type CacheReadPolicy, DatafileCache, + Freshness, } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { HeaderSource } from './header-source'; @@ -471,16 +472,15 @@ export class Controller implements ControllerInterface { private getCacheReadPolicy(): CacheReadPolicy { if (this.state === 'vercel') { return { - isFresh: this.headerSource.getFreshnessCheck(), - isStale: this.headerSource.isStale, - revalidate: this.headerSource.revalidate, + getStatus: this.headerSource.getStatusCheck(), + fetch: this.headerSource.fetch, }; } // Stream/poll maintain their existing schedules; reads do not trigger I/O. return { - isFresh: () => this.isConnected, - isStale: () => true, + getStatus: () => + this.isConnected ? Freshness.Fresh : Freshness.Stale, }; } diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index ea49e130a..f7db8fcaa 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -895,7 +895,7 @@ describe('Vercel mode (black-box)', () => { it.each([ 0, -1, - ])('ignores a background response with version delta %i without extending freshness', async (delta) => { + ])('retains a background response with version delta %i and renews age only on confirmation', async (delta) => { const instance = client(); await instance.evaluate('feature'); vi.setSystemTime(TIMESTAMP + 9_000); @@ -913,9 +913,11 @@ describe('Vercel mode (black-box)', () => { vi.setSystemTime(TIMESTAMP + 10_001); mockDatafileResponse(TIMESTAMP + 1, true); expect(await instance.evaluate('feature')).toMatchObject({ - value: true, - metrics: { cacheStatus: 'MISS' }, + value: delta !== 0, + metrics: { cacheStatus: delta === 0 ? 'STALE' : 'MISS' }, }); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); expect(dataFetch).toHaveBeenCalledTimes(2); }); @@ -967,8 +969,9 @@ describe('Vercel mode (black-box)', () => { mockDatafileResponse(TIMESTAMP + 2, true); expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( - 'MISS', + delta === 0 ? 'STALE' : 'MISS', ); + await vi.advanceTimersByTimeAsync(0); expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); expect(dataFetch).toHaveBeenCalledTimes(3); }); From 7559ede1891fe20ecfd40106b1c84f7651dab5e7 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 11:30:51 +0200 Subject: [PATCH 10/41] feat(flags-core): assess stream and polling freshness by age --- .../vercel-flags-core/src/controller/index.ts | 33 ++-- .../src/controller/polling-source.ts | 4 + .../src/controller/stream-connection.ts | 4 +- .../src/controller/stream-source.ts | 6 + .../src/stale-if-error.test.ts | 165 +++++++++++++++--- .../src/stream-stale-if-error.test.ts | 65 ++++++- 6 files changed, 235 insertions(+), 42 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index bd84e3f98..4d86330a2 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -165,6 +165,9 @@ export class Controller implements ControllerInterface { this.transition('streaming'); } }; + private onStreamPing = () => { + this.cache.resetAge(); + }; private onStreamConnected = () => { if (this.state === 'degraded' || this.state === 'initializing:stream') { this.transition('streaming'); @@ -177,6 +180,7 @@ export class Controller implements ControllerInterface { } }; private onSourceError = (error: Error) => { + this.noteUnauthorized(error); this.cache.fail(error); }; private onPollData = (data: DatafileInput) => { @@ -189,11 +193,6 @@ export class Controller implements ControllerInterface { private onHeaderConfirmed = (data: CacheMetadata) => { this.cache.tryConfirm(data); }; - private onPollError = (error: Error) => { - this.noteUnauthorized(error); - this.cache.fail(error); - console.error('@vercel/flags-core: Poll failed:', error); - }; // --------------------------------------------------------------------------- // Source event wiring @@ -202,11 +201,12 @@ export class Controller implements ControllerInterface { private wireSourceEvents(): void { this.streamSource.on('data', this.onStreamData); this.streamSource.on('primed', this.onStreamPrimed); + this.streamSource.on('ping', this.onStreamPing); this.streamSource.on('connected', this.onStreamConnected); this.streamSource.on('disconnected', this.onStreamDisconnected); this.streamSource.on('error', this.onSourceError); this.pollingSource.on('data', this.onPollData); - this.pollingSource.on('error', this.onPollError); + this.pollingSource.on('error', this.onSourceError); this.headerSource.on('data', this.onHeaderData); this.headerSource.on('confirmed', this.onHeaderConfirmed); } @@ -214,11 +214,12 @@ export class Controller implements ControllerInterface { private unwireSourceEvents(): void { this.streamSource.off('data', this.onStreamData); this.streamSource.off('primed', this.onStreamPrimed); + this.streamSource.off('ping', this.onStreamPing); this.streamSource.off('connected', this.onStreamConnected); this.streamSource.off('disconnected', this.onStreamDisconnected); this.streamSource.off('error', this.onSourceError); this.pollingSource.off('data', this.onPollData); - this.pollingSource.off('error', this.onPollError); + this.pollingSource.off('error', this.onSourceError); this.headerSource.off('data', this.onHeaderData); this.headerSource.off('confirmed', this.onHeaderConfirmed); } @@ -395,7 +396,10 @@ export class Controller implements ControllerInterface { if (this.options.buildStep) { [result, cacheStatus] = await this.resolveDataForBuildStep(); } else if (result) { - cacheStatus = this.isConnected ? 'HIT' : 'STALE'; + const status = this.getBackgroundSourceStatus({ + ageMs: this.cache.ageMs, + }); + cacheStatus = status === Freshness.Fresh ? 'HIT' : 'STALE'; } else { // Preserve snapshot loading without starting stream/poll initialization. const bundled = await this.bundledSource.tryLoad(); @@ -478,12 +482,17 @@ export class Controller implements ControllerInterface { } // Stream/poll maintain their existing schedules; reads do not trigger I/O. - return { - getStatus: () => - this.isConnected ? Freshness.Fresh : Freshness.Stale, - }; + return { getStatus: this.getBackgroundSourceStatus }; } + private getBackgroundSourceStatus = (data: Pick) => { + if (this.isConnected) return this.streamSource.getStatus(data); + if (this.state === 'polling' || this.state === 'initializing:polling') { + return this.pollingSource.getStatus(data); + } + return Freshness.Unknown; + }; + // --------------------------------------------------------------------------- // Stream initialization // --------------------------------------------------------------------------- diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index 963a0b41b..baa688424 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -1,5 +1,6 @@ import type { DatafileInput } from '../types'; import type { Auth } from './auth'; +import { type CacheMetadata, Freshness } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { TypedEmitter } from './typed-emitter'; @@ -31,6 +32,9 @@ export class PollingSource extends TypedEmitter { this.config = config; } + getStatus = ({ ageMs }: Pick): Freshness => + ageMs <= this.config.polling.intervalMs ? Freshness.Fresh : Freshness.Stale; + /** * Perform a single poll request. * Emits 'data' on success, 'error' on failure. diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index c274ac613..276a65939 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -47,6 +47,7 @@ class TokenResolutionError extends Error { export type StreamCallbacks = { onDatafile: (data: BundledDefinitions) => void; onPrimed?: (message: PrimedMessage) => void; + onPing?: () => void; onDisconnect?: () => void; onError?: (error: Error) => void; }; @@ -72,7 +73,7 @@ export async function connectStream( callbacks: StreamCallbacks, ): Promise { const { host, abortController, fetch: fetchFn = globalThis.fetch } = config; - const { onDatafile, onPrimed, onDisconnect, onError } = callbacks; + const { onDatafile, onPrimed, onPing, onDisconnect, onError } = callbacks; let retryCount = 0; let lastAttemptTime = 0; @@ -247,6 +248,7 @@ export async function connectStream( // Pings prove the connection is alive — reset retry count // once initial data has been received if (message.type === 'ping' && initialDataReceived) { + onPing?.(); retryCount = 0; resetPingTimeout(); } diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index 593d2d8f5..eaaf06527 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -1,4 +1,5 @@ import type { DatafileInput } from '../types'; +import { type CacheMetadata, Freshness } from './datafile-cache'; import type { NormalizedOptions } from './normalized-options'; import { connectStream, type PrimedMessage } from './stream-connection'; import { TypedEmitter } from './typed-emitter'; @@ -6,6 +7,7 @@ import { TypedEmitter } from './typed-emitter'; export type StreamSourceEvents = { data: (data: DatafileInput) => void; primed: (message: PrimedMessage) => void; + ping: () => void; connected: () => void; disconnected: () => void; error: (error: Error) => void; @@ -27,6 +29,9 @@ export class StreamSource extends TypedEmitter { this.revision = revision; } + getStatus = ({ ageMs }: Pick): Freshness => + ageMs <= 30_000 ? Freshness.Fresh : Freshness.Stale; + /** * Start the stream connection. * Returns a promise that resolves when the first datafile or primed message arrives. @@ -71,6 +76,7 @@ export class StreamSource extends TypedEmitter { this.emit('primed', message); this.emit('connected'); }, + onPing: () => this.emit('ping'), onDisconnect: () => { this.emit('disconnected'); }, diff --git a/packages/vercel-flags-core/src/stale-if-error.test.ts b/packages/vercel-flags-core/src/stale-if-error.test.ts index dac7426f9..560c03f8e 100644 --- a/packages/vercel-flags-core/src/stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stale-if-error.test.ts @@ -63,19 +63,11 @@ function client(options: CreateClientOptions = {}): FlagsClient { return result; } -function expectErrors(...errors: Error[]) { - expect(errorSpy.mock.calls).toEqual( - errors.map((error) => ['@vercel/flags-core: Poll failed:', error]), - ); - errorSpy.mockClear(); -} - function rejectPollOnce(error: Error) { for (let attempt = 0; attempt < 3; attempt++) { poll.mockRejectedValueOnce(error); } } - beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(0); @@ -125,9 +117,11 @@ describe('polling stale-if-error through the public API', () => { const failure = new Error('offline'); poll.mockRejectedValue(failure); await vi.advanceTimersByTimeAsync(90_300); - expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); expect(poll).toHaveBeenCalledTimes(10); - expectErrors(failure, failure, failure); }); it('includes the finite deadline, preserves the first error, and uses existing error/default/bulk conventions', async () => { @@ -137,7 +131,7 @@ describe('polling stale-if-error through the public API', () => { readMs: 0, evaluationMs: 0, source: 'in-memory', - cacheStatus: 'STALE', + cacheStatus: 'HIT', connectionState: 'disconnected', mode: 'polling', }); @@ -149,7 +143,10 @@ describe('polling stale-if-error through the public API', () => { await vi.advanceTimersByTimeAsync(30_300); expect(await instance.evaluate('flagA')).toEqual(initial); await vi.advanceTimersByTimeAsync(30_000); - expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); await vi.advanceTimersByTimeAsync(1); await expect(instance.evaluate('flagA')).rejects.toBe(first); const fallback = { @@ -176,7 +173,6 @@ describe('polling stale-if-error through the public API', () => { expect(retained).toEqual(snapshot); expect(retained.definitions).toBe(snapshot.definitions); expect(poll).toHaveBeenCalledTimes(8); - expectErrors(first, repeated); }); it('does not expire healthy data between polls or start a poll from reads', async () => { @@ -191,6 +187,121 @@ describe('polling stale-if-error through the public API', () => { expect(poll).toHaveBeenCalledTimes(2); }); + it('marks data stale after the polling interval and resets age on an equal response', async () => { + const instance = client({ + polling: { intervalMs: 45_000, initTimeoutMs: 3_000 }, + }); + const initial = await instance.evaluate('flagA'); + const snapshot = await instance.getDatafile(); + const pending = deferred(); + poll.mockReturnValueOnce(pending.promise); + + await vi.advanceTimersByTimeAsync(45_000); + expect(await instance.evaluate('flagA')).toEqual(initial); + expect((await instance.getDatafile()).metrics.cacheStatus).toBe('HIT'); + await vi.advanceTimersByTimeAsync(1); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); + expect((await instance.getDatafile()).metrics.cacheStatus).toBe('STALE'); + expect(poll).toHaveBeenCalledTimes(2); + + pending.resolve(response(data())); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('flagA')).toEqual(initial); + const confirmed = await instance.getDatafile(); + expect(confirmed).toEqual(snapshot); + expect(confirmed.definitions).toBe(snapshot.definitions); + expect(confirmed.fetchedAt).toBe(0); + expect(poll).toHaveBeenCalledTimes(2); + }); + + it.each([ + 'provided', + 'bundled', + ] as const)('tracks %s freshness through equal polls without replacing the snapshot or changing lifecycle', async (seed) => { + vi.setSystemTime(100_000); + const supplied = Object.freeze(data({ fetchedAt: 1_000 })); + if (seed === 'bundled') { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: supplied, + state: 'ok', + }); + } + const instance = client({ + polling: { intervalMs: 45_000, initTimeoutMs: 3_000 }, + ...(seed === 'provided' ? { datafile: supplied } : {}), + }); + const initial = await instance.evaluate('flagA'); + expect(initial.metrics).toEqual({ + readMs: 0, + evaluationMs: 0, + source: seed === 'provided' ? 'in-memory' : 'embedded', + cacheStatus: 'HIT', + connectionState: 'disconnected', + mode: 'offline', // Preserve the existing initialization lifecycle. + }); + const snapshot = await instance.getDatafile(); + expect(snapshot.fetchedAt).toBe(1_000); + expect(snapshot.definitions).toBe(supplied.definitions); + expect(snapshot.metrics.cacheStatus).toBe('HIT'); + expect(poll).toHaveBeenCalledTimes(1); + const pending = deferred(); + poll.mockReturnValueOnce(pending.promise); + + await vi.advanceTimersByTimeAsync(44_999); + expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.getDatafile()).toEqual(snapshot); + expect(poll).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.getDatafile()).toEqual(snapshot); + expect(poll).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); + expect(await instance.getDatafile()).toEqual({ + ...snapshot, + metrics: { ...snapshot.metrics, cacheStatus: 'STALE' }, + }); + expect(poll).toHaveBeenCalledTimes(2); + + pending.resolve(response(data())); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('flagA')).toEqual(initial); + const confirmed = await instance.getDatafile(); + expect(confirmed).toEqual(snapshot); + expect(confirmed.definitions).toBe(supplied.definitions); + expect(confirmed.segments).toBe(supplied.segments); + expect(confirmed.fetchedAt).toBe(1_000); + expect(poll).toHaveBeenCalledTimes(2); + }); + + it.each([ + { configUpdatedAt: 9 }, + { projectId: 'other' }, + { environment: 'preview' }, + ])('does not renew polling freshness for a rejected response %j', async (override) => { + const instance = client({ + polling: { intervalMs: 45_000, initTimeoutMs: 3_000 }, + }); + await instance.evaluate('flagA'); + const snapshot = await instance.getDatafile(); + poll.mockResolvedValueOnce(response(data(override))); + await vi.advanceTimersByTimeAsync(45_001); + expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect(await instance.getDatafile()).toEqual({ + ...snapshot, + metrics: { ...snapshot.metrics, cacheStatus: 'STALE' }, + }); + expect(poll).toHaveBeenCalledTimes(2); + }); + it('accepts fractional seconds and expires just after the inclusive millisecond deadline', async () => { const instance = client({ staleIfError: 0.25 }); const initial = await instance.evaluate('flagA'); @@ -198,14 +309,19 @@ describe('polling stale-if-error through the public API', () => { rejectPollOnce(failure); await vi.advanceTimersByTimeAsync(30_300); await vi.advanceTimersByTimeAsync(249); - expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); await vi.advanceTimersByTimeAsync(1); - expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); await vi.advanceTimersByTimeAsync(1); await expect(instance.evaluate('flagA')).rejects.toBe(failure); await expect(instance.getDatafile()).rejects.toBe(failure); expect(poll).toHaveBeenCalledTimes(4); - expectErrors(failure); }); it.each([ @@ -242,7 +358,6 @@ describe('polling stale-if-error through the public API', () => { snapshot.definitions, ); expect(poll).toHaveBeenCalledTimes(4); - expectErrors(failure); }); it.each([ @@ -274,7 +389,6 @@ describe('polling stale-if-error through the public API', () => { await vi.advanceTimersByTimeAsync(1); await expect(instance.evaluate('flagA')).rejects.toBe(failure); expect(poll).toHaveBeenCalledTimes(8); - expectErrors(failure, failure); }); it('recovers when polling reuses the same bundled object without changing its embedded origin', async () => { @@ -290,7 +404,7 @@ describe('polling stale-if-error through the public API', () => { readMs: 0, evaluationMs: 0, source: 'embedded', - cacheStatus: 'STALE', + cacheStatus: 'HIT', connectionState: 'disconnected', mode: 'offline', // Bundled initialization retains the existing lifecycle state. }); @@ -312,7 +426,6 @@ describe('polling stale-if-error through the public API', () => { expect(retained.definitions).toBe(supplied.definitions); expect(retained.segments).toBe(supplied.segments); expect(poll).toHaveBeenCalledTimes(5); - expectErrors(failure); }); it.each([ @@ -338,7 +451,6 @@ describe('polling stale-if-error through the public API', () => { snapshot.definitions, ); expect(poll).toHaveBeenCalledTimes(6); - expectErrors(failure); }); it.each([ @@ -353,7 +465,6 @@ describe('polling stale-if-error through the public API', () => { rejectPollOnce(failure); await vi.advanceTimersByTimeAsync(60_000); await expect(instance.evaluate('flagA')).rejects.toBe(failure); - expectErrors(failure); }); it('retains main acceptance of a newer mismatched identity and positive Infinity', async () => { @@ -368,7 +479,6 @@ describe('polling stale-if-error through the public API', () => { expect((await instance.getDatafile()).definitions).toBe( accepted.definitions, ); - expectErrors(failure); }); it('does not start SIE at initialization timeout; a late actual error does', async () => { @@ -392,7 +502,6 @@ describe('polling stale-if-error through the public API', () => { await expect(instance.evaluate('flagA')).rejects.toBe(failure); await vi.advanceTimersByTimeAsync(60_000); expect(poll).toHaveBeenCalledTimes(3); // Main starts no interval after this timeout. - expectErrors(failure); }); it.each([ @@ -425,7 +534,6 @@ describe('polling stale-if-error through the public API', () => { await restoredOutcome; await expect(instance.getDatafile()).rejects.toBe(failure); expect(poll).toHaveBeenCalledTimes(6); - expectErrors(failure); }); it('settles a timed-out poll before the next interval can recover', async () => { @@ -450,8 +558,6 @@ describe('polling stale-if-error through the public API', () => { await vi.advanceTimersByTimeAsync(20_000); expect((await instance.evaluate('flagA')).value).toBe(true); expect(poll).toHaveBeenCalledTimes(3); - expect(errorSpy).toHaveBeenCalledTimes(1); - errorSpy.mockClear(); }); it('keeps healthy streaming data with zero even when polling is configured', async () => { @@ -469,7 +575,10 @@ describe('polling stale-if-error through the public API', () => { const initial = await instance.evaluate('flagA'); expect(initial.metrics?.mode).toBe('streaming'); await vi.advanceTimersByTimeAsync(60_000); - expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(poll).not.toHaveBeenCalled(); }); diff --git a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts index 7d26c7ef8..04e9305f2 100644 --- a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts @@ -155,6 +155,61 @@ afterEach(async () => { }); describe('stream stale-if-error through the public API', () => { + it.each([ + 'ping', + 'primed', + ] as const)('resets stream freshness on %s without changing the fetched snapshot', async (type) => { + const { instance, stream } = await start({ staleIfError: 0 }); + const initial = await instance.evaluate('flagA'); + const snapshot = await instance.getDatafile(); + await vi.advanceTimersByTimeAsync(30_000); + expect(await instance.evaluate('flagA')).toEqual(initial); + expect((await instance.getDatafile()).metrics.cacheStatus).toBe('HIT'); + await vi.advanceTimersByTimeAsync(1); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); + expect((await instance.getDatafile()).metrics.cacheStatus).toBe('STALE'); + + stream.push(type === 'ping' ? { type } : primed()); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('flagA')).toEqual(initial); + const confirmed = await instance.getDatafile(); + expect(confirmed).toEqual(snapshot); + expect(confirmed.definitions).toBe(snapshot.definitions); + expect(confirmed.fetchedAt).toBe(0); + await vi.advanceTimersByTimeAsync(30_000); + expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe('HIT'); + await vi.advanceTimersByTimeAsync(1); + expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expectRequests(['0']); + }); + + it('does not renew stream freshness on an invalid confirmation', async () => { + const { instance, stream } = await start(); + const snapshot = await instance.getDatafile(); + await vi.advanceTimersByTimeAsync(30_001); + for (const override of [ + { revision: 6 }, + { projectId: 'other' }, + { environment: 'preview' }, + ]) { + stream.push(primed(override)); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect(await instance.getDatafile()).toEqual({ + ...snapshot, + metrics: { ...snapshot.metrics, cacheStatus: 'STALE' }, + }); + } + expectRequests(['0']); + }); + it.each([ undefined, Infinity, @@ -195,14 +250,22 @@ describe('stream stale-if-error through the public API', () => { // These messages emit connected but neither confirms the cached snapshot. reconnect.push(primed({ revision: 6 })); reconnect.push({ type: 'datafile', data: data({ configUpdatedAt: 9 }) }); + reconnect.push({ type: 'ping' }); await vi.advanceTimersByTimeAsync(1_000); expect(await instance.evaluate('flagA')).toMatchObject({ value: true, - metrics: { mode: 'streaming', connectionState: 'connected' }, + metrics: { + mode: 'streaming', + connectionState: 'connected', + cacheStatus: 'HIT', + }, }); expect((await instance.getDatafile()).configUpdatedAt).toBe(10); await vi.advanceTimersByTimeAsync(1); await expectExpired(instance, first); + reconnect.push({ type: 'ping' }); + await vi.advanceTimersByTimeAsync(0); + await expectExpired(instance, first); const fallback = { value: false, variantId: null, From 994274fdacdf9a437b5d169fe0d8b737f4637b63 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 11:31:31 +0200 Subject: [PATCH 11/41] test(flags-core): cover and document header freshness confirmations --- .changeset/header-driven-vercel-mode.md | 2 +- packages/vercel-flags-core/CLAUDE.md | 26 +- packages/vercel-flags-core/README.md | 14 +- .../controller/datafile-cache-policy.test.ts | 279 ++++++++++++++++++ 4 files changed, 304 insertions(+), 17 deletions(-) diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index 2c3975dac..745bc9ff2 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -4,6 +4,6 @@ Add a header-driven `vercel` client mode, enabled by default when `VERCEL=1`. Initialization loads provided/bundled definitions; request versions trigger refreshes and an empty cache fetches on its first read. Explicit offline/build behavior is preserved. -The controller supplies source freshness, staleness, and revalidation callbacks to the cache. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. Streaming/polling retain their existing schedules. +The controller supplies a source freshness-status callback and optional fetch callback to the cache. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Polling becomes stale after its interval, streaming after 30 seconds; stream pings reset age without clearing or extending a stale-if-error failure. Source schedules stay unchanged. Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 25808742a..beca5bd2a 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -129,12 +129,12 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu **Vercel runtime** (`vercel: true`, default when `VERCEL=1`): - Load provided or bundled definitions during initialization, then select Vercel mode. - Do not start stream/poll; the first read fetches if the cache is empty. -- HeaderSource parses the request's project version and owns `highestObserved` and `lastSeen`. +- HeaderSource parses the request's project version and owns `highestObserved`. The cache owns freshness age. - A matching header confirms freshness only when no newer version has been observed. -- The controller passes `isFresh`, `isStale`, and `revalidate` callbacks to `cache.resolve()`. +- The controller passes `getStatus` and `fetch` callbacks to `cache.resolve()`. The cache selects cached/background/blocking behavior and shares refresh work. - A newer header permits background refresh within `staleWhileRevalidate` seconds of the - latest accepted fetch or matching header; unknown/expired freshness blocks for refresh. + latest accepted fetch or valid confirmation; unknown/expired cache age blocks for refresh. - Every returned entry passes through `DatafileCache.read()`. Refresh errors use its `staleIfErrorMs` allowance; expiry forces blocking recovery on the next newer-header read. - Missing/malformed headers use cached data without fetching, subject to stale-if-error. @@ -261,9 +261,9 @@ When updating tests for new behavior, preserve the strength of existing assertio ### Stream Connection - Uses fetch with streaming body (NDJSON format) -- Callbacks: `onDatafile` (new data), `onPrimed` (server confirmed revision is current), `onDisconnect`, and `onError` (failure evidence for cache policy) +- Callbacks: `onDatafile` (new data), `onPrimed` (server confirmed revision is current), `onPing` (age-only reset), `onDisconnect`, and `onError` (failure evidence for cache policy) - Sends `X-Revision` header with the current revision number on every connection (including reconnects), allowing the server to respond with a lightweight `primed` message instead of a full datafile when the revision is current -- The `primed` message confirms the client's data is up-to-date; it resolves the init promise (like `datafile`) but does not update data — only transitions state to `streaming` +- The `primed` message confirms the client's data is up-to-date; it resolves the init promise (like `datafile`) but does not update data — resets cache age and clears a failure when revision/identity match, then transitions state to `streaming` - Reconnects with exponential backoff (base: 1s, max: 60s, max retries: 15) - Retries on transient errors both before and after initial data is received. Before initial data, retries continue until max retries are exhausted or the abort controller is aborted (e.g., by the Controller's init timeout). The init promise rejects when the loop exits without data. - Default `initTimeoutMs`: 3000ms @@ -292,8 +292,9 @@ The Controller selects the origin. Initial/fallback snapshots are tagged before `tagData` returns a shallow copy. Accepted fetched/stream/poll data is stamped with `fetchedAt`; provided and bundled data preserves valid finite nonnegative timestamps. -Missing/invalid timestamps mean unknown fetch age. Loading data never resets its age, -and equal/older source responses do not replace or retag the cache. +The cache seeds its own freshness age from that timestamp; missing/invalid timestamps +mean unknown age. Accepted updates and valid confirmations reset cache age without +rewriting the stored `fetchedAt`. Equal/older responses do not replace or retag data. ### Usage Tracking @@ -336,13 +337,16 @@ version/revision confirmations clear it; repeated errors/disconnects do not rene the first-error deadline. Stream opening/pings and initialization timeout alone are not recovery/failure evidence respectively. -`cache.resolve(policy)` receives mode-specific `isFresh`, `isStale`, and optional -`revalidate` functions. It owns background/blocking decisions, `waitUntil`, shared -revalidation, and cancellation on clear. HeaderSource supplies small version/age +`cache.resolve(policy)` receives a mode-specific `getStatus` callback returning +`Freshness.Fresh`, `Stale`, `Expired`, or `Unknown`, and an optional `fetch` callback. +It owns background/blocking decisions, `waitUntil`, shared revalidation, and cancellation on clear. HeaderSource supplies small version/age checks and a fetch callback; it does not read the cache. Header confirmations are forwarded through controller event wiring. Stream/poll modes omit on-read revalidation and retain their existing schedules. New public time windows use seconds; internal -normalized durations and `fetchedAt` use milliseconds. +normalized durations, cache age, and `fetchedAt` use milliseconds. Polling is fresh +through its interval; streaming through 30 seconds. Accepted updates, valid confirmations, +and stream pings reset cache age. Pings preserve any failure and its original deadline. +Polling errors use the shared source-error handler without logging each failed poll. ### Evaluation Safety diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index 9389bcda3..8067da359 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -50,8 +50,9 @@ const client = createClient(process.env.FLAGS!, { `staleWhileRevalidate` defaults to 10 seconds and accepts finite, nonnegative values, including fractions. `0` makes refreshes block. The window starts at the latest -accepted fetch or matching request-header confirmation. Bundled/provided definitions -preserve their original `fetchedAt`; unknown or expired freshness requires a blocking +accepted fetch or valid confirmation, including an equal-version fetch response. +The cache tracks this age independently of `fetchedAt`. Bundled/provided definitions +preserve their original `fetchedAt`; unknown or expired cache age requires a blocking refresh when a newer request version arrives. Refresh failures use `staleIfError`. A newer-header read attempts blocking recovery after that failure allowance expires. @@ -92,9 +93,12 @@ retained for recovery, including its revision for stream reconnection. A clean stream close or ping timeout records `stream: disconnected` if no earlier failure exists. `getFallbackDatafile()` remains an independent bundled-data export. -Stream/poll modes have no age-based expiry while the source is healthy, and reads -do not trigger an extra refresh after expiry. Build/offline behavior, source scheduling, retries, -timeouts, metrics categories, and logging are unchanged. An initialization timeout +Polling data is marked stale after the polling interval; streaming data after 30 +seconds. Accepted updates and valid confirmations reset cache age without rewriting +`fetchedAt`. Stream pings also reset age, while preserving any failure and its deadline. +Age alone does not prevent stream/poll reads or trigger extra requests. Source scheduling, +retries, timeouts, and build/offline behavior remain unchanged. Poll errors feed the +shared failure handler without logging each failed poll. An initialization timeout alone does not start the allowance. Existing startup limitations remain: when initial polling times out, no recurring interval is started, even if that in-flight request later completes. diff --git a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts index 3a1e8f2ee..723cb4220 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts @@ -1,12 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DatafileInput } from '../types'; +import { getRequestContext } from '../utils/request-context'; +import { Authentication } from './auth'; import { type CacheReadPolicy, DatafileCache, Freshness, } from './datafile-cache'; +import { fetchDatafile } from './fetch-datafile'; +import { HeaderSource } from './header-source'; +import { normalizeOptions } from './normalized-options'; import { tagData } from './tagged-data'; +vi.mock('../utils/request-context', () => ({ getRequestContext: vi.fn() })); +vi.mock('./fetch-datafile', () => ({ fetchDatafile: vi.fn() })); + function data(configUpdatedAt = 1): DatafileInput { return { projectId: 'prj_policy', @@ -30,6 +38,12 @@ let errorSpy: ReturnType; let warnSpy: ReturnType; beforeEach(() => { vi.useFakeTimers({ now: 1_000 }); + vi.mocked(getRequestContext).mockReset(); + vi.mocked(getRequestContext).mockReturnValue({ + ctx: undefined, + headers: undefined, + }); + vi.mocked(fetchDatafile).mockReset(); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -297,3 +311,268 @@ describe('cache read callbacks', () => { expect(cache.read()?.configUpdatedAt).toBe(2); }); }); + +describe('header freshness policy', () => { + function source(staleWhileRevalidate = 1) { + return new HeaderSource( + normalizeOptions({ + auth: new Authentication(undefined), + vercel: true, + staleWhileRevalidate, + }), + ); + } + + function statusCheck(headerSource: HeaderSource, header: string | undefined) { + vi.mocked(getRequestContext).mockReturnValue({ + ctx: undefined, + headers: + header === undefined + ? undefined + : { 'x-vercel-flags-config-versions': header }, + }); + return headerSource.getStatusCheck(); + } + + it.each([ + undefined, + '', + 'flags_other=2', + 'flags_prj_policy=invalid', + 'flags_prj_policy=0', + 'flags_prj_policy=-1', + 'flags_prj_policy=Infinity', + ])('treats missing or malformed header %s as unknown', (header) => { + const headerSource = source(); + const confirmed = vi.fn(); + headerSource.on('confirmed', confirmed); + expect(statusCheck(headerSource, header)({ ...data(), ageMs: 0 })).toBe( + Freshness.Unknown, + ); + expect(confirmed).not.toHaveBeenCalled(); + }); + + it.each([ + undefined, + '', + 'invalid', + NaN, + Infinity, + 0, + ])('treats missing or invalid cached timestamp %s as unknown', (configUpdatedAt) => { + const headerSource = source(); + const confirmed = vi.fn(); + headerSource.on('confirmed', confirmed); + expect( + statusCheck( + headerSource, + 'flags_prj_policy=2', + )({ + ...data(), + configUpdatedAt, + ageMs: 0, + }), + ).toBe(Freshness.Unknown); + expect(confirmed).not.toHaveBeenCalled(); + }); + + it.each([ + [1, 2, Infinity, 1, Freshness.Fresh], + [2, 2, Infinity, 1, Freshness.Fresh], + [3, 2, 0, 1, Freshness.Stale], + [3, 2, 1_000, 1, Freshness.Stale], + [3, 2, 1_001, 1, Freshness.Expired], + [3, 2, Infinity, 1, Freshness.Expired], + [3, 2, 0, 0, Freshness.Expired], + [3, 2, 500, 0.5, Freshness.Stale], + [3, 2, 501, 0.5, Freshness.Expired], + ])('assesses header %s against timestamp %s with age %s and SWR %s as %s', (headerTs, currentTs, ageMs, swr, status) => { + const headerSource = source(swr); + const confirmed = vi.fn(); + headerSource.on('confirmed', confirmed); + const metadata = { ...data(currentTs), ageMs }; + expect( + statusCheck( + headerSource, + `flags_other=99; flags_prj_policy=${headerTs}`, + )(metadata), + ).toBe(status); + expect(confirmed).toHaveBeenCalledTimes(headerTs === currentTs ? 1 : 0); + }); + + it('resets cache age on an equal highest-observed header, then blocks older confirmations until stop', async () => { + const cache = new DatafileCache(0); + const original = Object.freeze( + tagData({ ...data(), fetchedAt: 500 }, 'bundled'), + ); + cache.seed(original); + const headerSource = source(); + const confirmed = vi.fn((metadata) => cache.tryConfirm(metadata)); + headerSource.on('confirmed', confirmed); + expect( + await cache.resolve({ + getStatus: statusCheck(headerSource, 'flags_prj_policy=1'), + }), + ).toEqual([original, 'HIT']); + expect(cache.ageMs).toBe(0); + expect(original.fetchedAt).toBe(500); + expect(original._origin).toBe('bundled'); + expect(confirmed).toHaveBeenCalledTimes(1); + + vi.setSystemTime(1_100); + expect( + await cache.resolve({ + getStatus: statusCheck(headerSource, 'flags_prj_policy=2'), + }), + ).toEqual([original, 'STALE']); + const error = new Error('outage'); + cache.fail(error); + await expect( + cache.resolve({ + getStatus: statusCheck(headerSource, 'flags_prj_policy=1'), + }), + ).rejects.toBe(error); + expect(cache.ageMs).toBe(100); + expect(confirmed).toHaveBeenCalledTimes(1); + + headerSource.stop(); + expect( + await cache.resolve({ + getStatus: statusCheck(headerSource, 'flags_prj_policy=1'), + }), + ).toEqual([original, 'HIT']); + expect(cache.ageMs).toBe(0); + expect(confirmed).toHaveBeenCalledTimes(2); + expect(original.fetchedAt).toBe(500); + }); + + it('assesses the captured raw header after a shared cold fetch discovers the project', async () => { + const cache = new DatafileCache(0); + const headerSource = source(); + const confirmed = vi.fn((metadata) => cache.tryConfirm(metadata)); + headerSource.on('confirmed', confirmed); + const headers = { 'x-vercel-flags-config-versions': 'flags_prj_policy=2' }; + vi.mocked(getRequestContext).mockReturnValue({ ctx: undefined, headers }); + const originalCheck = vi.fn(headerSource.getStatusCheck()); + const pending = deferred(); + const fetch = vi.fn(async () => { + await pending.promise; + cache.updateFromSource(data(), 'fetched'); + }); + const firstRead = cache.resolve({ getStatus: originalCheck, fetch }); + headers['x-vercel-flags-config-versions'] = 'flags_prj_policy=1'; + const laterCheck = vi.fn(headerSource.getStatusCheck()); + const secondRead = cache.resolve({ getStatus: laterCheck, fetch }); + await vi.advanceTimersByTimeAsync(0); + expect(originalCheck).not.toHaveBeenCalled(); + expect(laterCheck).not.toHaveBeenCalled(); + pending.resolve(); + expect(await Promise.all([firstRead, secondRead])).toEqual([ + [cache.read(), 'MISS'], + [cache.read(), 'MISS'], + ]); + expect(fetch).toHaveBeenCalledTimes(1); + expect(originalCheck).toHaveReturnedWith(Freshness.Stale); + expect(laterCheck).toHaveReturnedWith(Freshness.Fresh); + expect(originalCheck).toHaveBeenCalledTimes(1); + expect(laterCheck).toHaveBeenCalledTimes(1); + expect(confirmed).not.toHaveBeenCalled(); + + vi.setSystemTime(1_100); + cache.fail(new Error('outage')); + await expect(cache.resolve({ getStatus: laterCheck })).rejects.toThrow( + 'outage', + ); + expect(cache.ageMs).toBe(100); + expect(confirmed).not.toHaveBeenCalled(); + }); + + it('accepts the fallback header and gives the Vercel header precedence', () => { + const headerSource = source(); + vi.mocked(getRequestContext).mockReturnValue({ + ctx: undefined, + headers: { 'flags-config-versions': 'flags_prj_policy=1' }, + }); + expect(headerSource.getStatusCheck()({ ...data(), ageMs: Infinity })).toBe( + Freshness.Fresh, + ); + vi.mocked(getRequestContext).mockReturnValue({ + ctx: undefined, + headers: { + 'x-vercel-flags-config-versions': 'flags_prj_policy=2', + 'flags-config-versions': 'flags_prj_policy=1', + }, + }); + expect(headerSource.getStatusCheck()({ ...data(), ageMs: Infinity })).toBe( + Freshness.Expired, + ); + }); + + it('emits raw fetched data and confirms equal responses without changing fetchedAt', async () => { + const cache = new DatafileCache(); + const original = Object.freeze( + tagData({ ...data(), fetchedAt: 500 }, 'bundled'), + ); + cache.seed(original); + const headerSource = source(); + const onData = vi.fn((raw) => cache.updateFromSource(raw, 'fetched')); + headerSource.on('data', onData); + const incoming = Object.freeze({ + ...data(), + configUpdatedAt: 1, + revision: 1, + digest: 'test', + }); + vi.mocked(fetchDatafile).mockResolvedValue(incoming); + const fetch = headerSource.fetch; + const signal = new AbortController().signal; + vi.setSystemTime(2_000); + await fetch(signal); + expect(onData).toHaveBeenCalledExactlyOnceWith(incoming); + expect(onData.mock.calls[0]?.[0]).toBe(incoming); + expect(fetchDatafile).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ signal }), + ); + expect(cache.read()).toBe(original); + expect(cache.ageMs).toBe(0); + expect(original.fetchedAt).toBe(500); + expect(original._origin).toBe('bundled'); + expect(incoming).not.toHaveProperty('_origin'); + expect(incoming).not.toHaveProperty('fetchedAt'); + expect( + await cache.resolve({ + getStatus: statusCheck(headerSource, 'flags_prj_policy=2'), + }), + ).toEqual([original, 'STALE']); + }); + + it('suppresses a successful transport response after cache clear cancels the fetch', async () => { + const cache = new DatafileCache(0); + const headerSource = source(); + const pending = deferred(); + vi.mocked(fetchDatafile).mockImplementation(async () => { + await pending.promise; + return { ...data(2), configUpdatedAt: 2, revision: 2, digest: 'test' }; + }); + const onData = vi.fn((raw) => cache.updateFromSource(raw, 'fetched')); + headerSource.on('data', onData); + const reading = cache.resolve({ + getStatus: statusCheck(headerSource, 'flags_prj_policy=2'), + fetch: headerSource.fetch, + }); + const outcome = expect(reading).rejects.toThrow(); + await vi.advanceTimersByTimeAsync(0); + expect(fetchDatafile).toHaveBeenCalledTimes(1); + const signal = vi.mocked(fetchDatafile).mock.calls[0]?.[0].signal; + cache.clear(); + pending.resolve(); + await outcome; + expect(signal?.aborted).toBe(true); + expect(onData).not.toHaveBeenCalled(); + expect(cache.read()).toBeUndefined(); + expect(cache.ageMs).toBe(Infinity); + const replacement = tagData(data(), 'provided'); + cache.seed(replacement); + expect(cache.read()).toBe(replacement); + }); +}); From 55431b702f8e42b969176284c4cab606fc4f1c9a Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 13:31:41 +0200 Subject: [PATCH 12/41] refactor(flags-core): simplify freshness policies while preserving recovery --- packages/vercel-flags-core/CLAUDE.md | 2 +- .../controller/datafile-cache-policy.test.ts | 73 ++++++++++--------- .../src/controller/datafile-cache.ts | 29 ++++---- .../src/controller/header-source.ts | 51 ++++++------- .../vercel-flags-core/src/controller/index.ts | 51 ++++++------- .../src/controller/polling-source.ts | 4 +- .../src/controller/stream-source.ts | 4 +- 7 files changed, 106 insertions(+), 108 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index beca5bd2a..4876832db 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -338,7 +338,7 @@ the first-error deadline. Stream opening/pings and initialization timeout alone are not recovery/failure evidence respectively. `cache.resolve(policy)` receives a mode-specific `getStatus` callback returning -`Freshness.Fresh`, `Stale`, `Expired`, or `Unknown`, and an optional `fetch` callback. +`fresh`, `stale`, `expired`, or `unknown`, and an optional `fetch` callback. It owns background/blocking decisions, `waitUntil`, shared revalidation, and cancellation on clear. HeaderSource supplies small version/age checks and a fetch callback; it does not read the cache. Header confirmations are forwarded through controller event wiring. Stream/poll modes omit on-read revalidation diff --git a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts index 723cb4220..d00ccc129 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts @@ -5,7 +5,7 @@ import { Authentication } from './auth'; import { type CacheReadPolicy, DatafileCache, - Freshness, + type Freshness, } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { HeaderSource } from './header-source'; @@ -58,16 +58,19 @@ afterEach(() => { }); describe('cache read callbacks', () => { - it.each( - Object.values(Freshness), - )('serves %s without fetching when fetch is omitted, subject to SIE', async (status) => { + it.each([ + 'expired', + 'fresh', + 'stale', + 'unknown', + ] satisfies Freshness[])('serves %s without fetching when fetch is omitted, subject to SIE', async (status) => { const cache = new DatafileCache(0); const original = tagData(data(), 'provided'); cache.seed(original); const policy = { getStatus: vi.fn(() => status) }; expect(await cache.resolve(policy)).toEqual([ original, - status === Freshness.Fresh ? 'HIT' : 'STALE', + status === 'fresh' ? 'HIT' : 'STALE', ]); const error = new Error('outage'); cache.fail(error); @@ -77,15 +80,15 @@ describe('cache read callbacks', () => { it('returns undefined without assessing an empty cache when fetch is omitted', async () => { const cache = new DatafileCache(); - const getStatus = vi.fn(() => Freshness.Fresh); + const getStatus = vi.fn(() => 'fresh' as const); expect(await cache.resolve({ getStatus })).toBeUndefined(); expect(getStatus).not.toHaveBeenCalled(); }); it.each([ - Freshness.Fresh, - Freshness.Unknown, - ])('serves a %s assessment without fetching or clearing a failure', async (status) => { + 'fresh', + 'unknown', + ] as const)('serves a %s assessment without fetching or clearing a failure', async (status) => { const cache = new DatafileCache(0); const original = tagData(data(), 'provided'); cache.seed(original); @@ -96,7 +99,7 @@ describe('cache read callbacks', () => { expect(await cache.resolve(policy)).toEqual([ original, - status === Freshness.Fresh ? 'HIT' : 'STALE', + status === 'fresh' ? 'HIT' : 'STALE', ]); expect(policy.getStatus).toHaveBeenCalledExactlyOnceWith({ projectId: 'prj_policy', @@ -123,7 +126,7 @@ describe('cache read callbacks', () => { }); const settled = vi.fn(); const reading = cache - .resolve({ getStatus: () => Freshness.Expired, fetch }) + .resolve({ getStatus: () => 'expired' as const, fetch }) .then(settled); await vi.advanceTimersByTimeAsync(0); expect(settled).not.toHaveBeenCalled(); @@ -144,7 +147,7 @@ describe('cache read callbacks', () => { .fn>() .mockRejectedValueOnce(firstError) .mockRejectedValue(new Error('later outage')); - const policy = { getStatus: () => Freshness.Expired, fetch }; + const policy = { getStatus: () => 'expired' as const, fetch }; expect(await cache.resolve(policy)).toEqual([original, 'STALE']); vi.setSystemTime(1_100); expect(await cache.resolve(policy)).toEqual([original, 'STALE']); @@ -158,7 +161,7 @@ describe('cache read callbacks', () => { cache.seed(tagData(data(), 'provided')); const fetch = vi.fn().mockRejectedValue('transport failed'); await expect( - cache.resolve({ getStatus: () => Freshness.Expired, fetch }), + cache.resolve({ getStatus: () => 'expired' as const, fetch }), ).rejects.toThrow('Unknown fetch error'); expect(() => cache.read()).toThrow('Unknown fetch error'); expect(fetch).toHaveBeenCalledTimes(1); @@ -174,7 +177,7 @@ describe('cache read callbacks', () => { const failure = new Error('fetch failed'); const fetch = vi.fn().mockRejectedValue(failure); expect( - await cache.resolve({ getStatus: () => Freshness.Stale, fetch }), + await cache.resolve({ getStatus: () => 'stale' as const, fetch }), ).toEqual([original, 'STALE']); await waitUntil.mock.calls[0]?.[0]; expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); @@ -197,12 +200,12 @@ describe('cache read callbacks', () => { await pending.promise; cache.updateFromSource(data(2), 'fetched'); }); - const policy = { getStatus: () => Freshness.Stale, fetch }; + const policy = { getStatus: () => 'stale' as const, fetch }; expect(await cache.resolve(policy)).toEqual([original, 'STALE']); expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); const settled = vi.fn(); const blocking = cache - .resolve({ ...policy, getStatus: () => Freshness.Expired }) + .resolve({ ...policy, getStatus: () => 'expired' as const }) .then((result) => { settled(); return result; @@ -225,7 +228,7 @@ describe('cache read callbacks', () => { const fetch = vi .fn>() .mockRejectedValueOnce(failure); - const policy = { getStatus: () => Freshness.Stale, fetch }; + const policy = { getStatus: () => 'stale' as const, fetch }; expect((await cache.resolve(policy))?.[1]).toBe('STALE'); await waitUntil.mock.calls[0]?.[0]; @@ -251,7 +254,7 @@ describe('cache read callbacks', () => { const fetch = vi.fn>(() => { throw failure; }); - const policy = { getStatus: () => Freshness.Expired, fetch }; + const policy = { getStatus: () => 'expired' as const, fetch }; await expect(cache.resolve(policy)).rejects.toBe(failure); fetch.mockImplementationOnce(async () => cache.updateFromSource(data(), 'fetched'), @@ -264,7 +267,7 @@ describe('cache read callbacks', () => { const cache = new DatafileCache(); const fetch = vi.fn(async () => {}); const reading = cache.resolve({ - getStatus: () => Freshness.Expired, + getStatus: () => 'expired' as const, fetch, }); const outcome = expect(reading).rejects.toThrow(); @@ -286,7 +289,7 @@ describe('cache read callbacks', () => { signal.throwIfAborted(); cache.updateFromSource(data(2), 'fetched'); }); - const policy = { getStatus: () => Freshness.Expired, fetch }; + const policy = { getStatus: () => 'expired' as const, fetch }; const oldRead = cache.resolve(policy); const cancelled = expect(oldRead).rejects.toThrow('cancelled transport'); await vi.advanceTimersByTimeAsync(0); @@ -347,7 +350,7 @@ describe('header freshness policy', () => { const confirmed = vi.fn(); headerSource.on('confirmed', confirmed); expect(statusCheck(headerSource, header)({ ...data(), ageMs: 0 })).toBe( - Freshness.Unknown, + 'unknown', ); expect(confirmed).not.toHaveBeenCalled(); }); @@ -372,20 +375,20 @@ describe('header freshness policy', () => { configUpdatedAt, ageMs: 0, }), - ).toBe(Freshness.Unknown); + ).toBe('unknown'); expect(confirmed).not.toHaveBeenCalled(); }); it.each([ - [1, 2, Infinity, 1, Freshness.Fresh], - [2, 2, Infinity, 1, Freshness.Fresh], - [3, 2, 0, 1, Freshness.Stale], - [3, 2, 1_000, 1, Freshness.Stale], - [3, 2, 1_001, 1, Freshness.Expired], - [3, 2, Infinity, 1, Freshness.Expired], - [3, 2, 0, 0, Freshness.Expired], - [3, 2, 500, 0.5, Freshness.Stale], - [3, 2, 501, 0.5, Freshness.Expired], + [1, 2, Infinity, 1, 'fresh'], + [2, 2, Infinity, 1, 'fresh'], + [3, 2, 0, 1, 'stale'], + [3, 2, 1_000, 1, 'stale'], + [3, 2, 1_001, 1, 'expired'], + [3, 2, Infinity, 1, 'expired'], + [3, 2, 0, 0, 'expired'], + [3, 2, 500, 0.5, 'stale'], + [3, 2, 501, 0.5, 'expired'], ])('assesses header %s against timestamp %s with age %s and SWR %s as %s', (headerTs, currentTs, ageMs, swr, status) => { const headerSource = source(swr); const confirmed = vi.fn(); @@ -472,8 +475,8 @@ describe('header freshness policy', () => { [cache.read(), 'MISS'], ]); expect(fetch).toHaveBeenCalledTimes(1); - expect(originalCheck).toHaveReturnedWith(Freshness.Stale); - expect(laterCheck).toHaveReturnedWith(Freshness.Fresh); + expect(originalCheck).toHaveReturnedWith('stale'); + expect(laterCheck).toHaveReturnedWith('fresh'); expect(originalCheck).toHaveBeenCalledTimes(1); expect(laterCheck).toHaveBeenCalledTimes(1); expect(confirmed).not.toHaveBeenCalled(); @@ -494,7 +497,7 @@ describe('header freshness policy', () => { headers: { 'flags-config-versions': 'flags_prj_policy=1' }, }); expect(headerSource.getStatusCheck()({ ...data(), ageMs: Infinity })).toBe( - Freshness.Fresh, + 'fresh', ); vi.mocked(getRequestContext).mockReturnValue({ ctx: undefined, @@ -504,7 +507,7 @@ describe('header freshness policy', () => { }, }); expect(headerSource.getStatusCheck()({ ...data(), ageMs: Infinity })).toBe( - Freshness.Expired, + 'expired', ); }); diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index d5ea96369..a3eb96371 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -8,12 +8,7 @@ type Confirmation = Pick< export type CacheMetadata = Confirmation & { ageMs: number }; -export enum Freshness { - Fresh = 'fresh', - Stale = 'stale', - Expired = 'expired', - Unknown = 'unknown', -} +export type Freshness = 'fresh' | 'stale' | 'expired' | 'unknown'; type Fetch = (signal: AbortSignal) => Promise; type CacheResult = [TaggedData, Metrics['cacheStatus']]; @@ -74,7 +69,7 @@ export class DatafileCache { } /** Freshness checks can inspect retained metadata even after serving expires. */ - private get metadata(): CacheMetadata | undefined { + public get metadata(): CacheMetadata | undefined { if (!this.data) return undefined; const { projectId, environment, configUpdatedAt, revision } = this.data; return { @@ -133,9 +128,14 @@ export class DatafileCache { return false; } + this.confirm(); + return true; + } + + /** Confirms the current cache state by clearing failures and resetting age. */ + confirm(): void { this.resetAge(); this.failure = undefined; - return true; } /** Preserves existing acceptance, including missing or unparseable versions. */ @@ -172,17 +172,15 @@ export class DatafileCache { } async resolve(policy: CacheReadPolicy): Promise { + // Expired entries can still recover through confirmation or a blocking fetch. const metadata = this.metadata; if (metadata) { const status = policy.getStatus(metadata); - if ( - status === Freshness.Fresh || - status === Freshness.Unknown || - !policy.fetch - ) { - return [this.read()!, status === Freshness.Fresh ? 'HIT' : 'STALE']; + if (status === 'fresh' || status === 'unknown' || !policy.fetch) { + return [this.read()!, status === 'fresh' ? 'HIT' : 'STALE']; } - if (status === Freshness.Stale && this.canServe()) { + + if (status === 'stale' && this.canServe()) { const stale = this.read()!; this.fetchInBackground(policy.fetch); return [stale, 'STALE']; @@ -190,6 +188,7 @@ export class DatafileCache { } if (!policy.fetch) return; + const { promise, signal } = this.startFetch(policy.fetch); try { await promise; diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 78d29b013..fbfc61976 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -1,10 +1,6 @@ import type { DatafileInput } from '../types'; import { getRequestContext } from '../utils/request-context'; -import { - type CacheMetadata, - type CacheReadPolicy, - Freshness, -} from './datafile-cache'; +import type { CacheMetadata, CacheReadPolicy } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import type { NormalizedOptions } from './normalized-options'; import { TypedEmitter } from './typed-emitter'; @@ -28,7 +24,28 @@ export class HeaderSource extends TypedEmitter { const header = headers?.['x-vercel-flags-config-versions'] ?? headers?.['flags-config-versions']; - return (data) => this.getStatus(data, header); + + return (data) => { + const headerTs = this.getUpdatedAtHeader(data.projectId, header); + if (headerTs === undefined) return 'unknown'; + + const currentTs = Number(data.configUpdatedAt); + this.highestObserved = Math.max(this.highestObserved, headerTs); + + if (!Number.isFinite(currentTs) || currentTs <= 0) return 'unknown'; + + // An older matching request cannot undo a newer request's invalidation. + if (headerTs === currentTs && headerTs === this.highestObserved) { + this.emit('confirmed', data); + } + + if (headerTs <= currentTs) return 'fresh'; + + const { staleWhileRevalidateMs } = this.options; + return staleWhileRevalidateMs > 0 && data.ageMs <= staleWhileRevalidateMs + ? 'stale' + : 'expired'; + }; } private getUpdatedAtHeader(projectId: string, header: string | undefined) { @@ -44,28 +61,6 @@ export class HeaderSource extends TypedEmitter { return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : undefined; } - private getStatus( - data: CacheMetadata, - header: string | undefined, - ): Freshness { - const headerTs = this.getUpdatedAtHeader(data.projectId, header); - if (headerTs === undefined) return Freshness.Unknown; - - const currentTs = Number(data.configUpdatedAt); - this.highestObserved = Math.max(this.highestObserved, headerTs); - if (!Number.isFinite(currentTs) || currentTs <= 0) return Freshness.Unknown; - // An older matching request cannot undo a newer request's invalidation. - if (headerTs === currentTs && headerTs === this.highestObserved) { - this.emit('confirmed', data); - } - - if (headerTs <= currentTs) return Freshness.Fresh; - const { staleWhileRevalidateMs } = this.options; - return staleWhileRevalidateMs > 0 && data.ageMs <= staleWhileRevalidateMs - ? Freshness.Stale - : Freshness.Expired; - } - fetch = async (signal: AbortSignal): Promise => { const data = await fetchDatafile({ ...this.options, signal }); // Transports can finish after cancellation; never publish that response. diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 4d86330a2..fa2651311 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -15,7 +15,6 @@ import { type CacheMetadata, type CacheReadPolicy, DatafileCache, - Freshness, } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { HeaderSource } from './header-source'; @@ -232,10 +231,6 @@ export class Controller implements ControllerInterface { this.state = to; } - private get isConnected(): boolean { - return this.state === 'streaming'; - } - private get mode(): Metrics['mode'] { if (this.options.buildStep) return 'build'; switch (this.state) { @@ -354,9 +349,10 @@ export class Controller implements ControllerInterface { readMs: Date.now() - startTime, source: originToMetricsSource(result._origin), cacheStatus, - connectionState: this.isConnected - ? ('connected' as const) - : ('disconnected' as const), + connectionState: + this.state === 'streaming' + ? ('connected' as const) + : ('disconnected' as const), mode: this.mode, }, } satisfies Datafile; @@ -396,10 +392,14 @@ export class Controller implements ControllerInterface { if (this.options.buildStep) { [result, cacheStatus] = await this.resolveDataForBuildStep(); } else if (result) { - const status = this.getBackgroundSourceStatus({ - ageMs: this.cache.ageMs, - }); - cacheStatus = status === Freshness.Fresh ? 'HIT' : 'STALE'; + const metadata = this.cache.metadata; + // Snapshots must not turn request headers into freshness evidence. + const status = + metadata && this.state !== 'vercel' + ? this.cacheReadPolicy.getStatus(metadata) + : 'unknown'; + + cacheStatus = status === 'fresh' ? 'HIT' : 'STALE'; } else { // Preserve snapshot loading without starting stream/poll initialization. const bundled = await this.bundledSource.tryLoad(); @@ -436,9 +436,10 @@ export class Controller implements ControllerInterface { readMs: Date.now() - startTime, source: originToMetricsSource(result._origin), cacheStatus, - connectionState: this.isConnected - ? ('connected' as const) - : ('disconnected' as const), + connectionState: + this.state === 'streaming' + ? ('connected' as const) + : ('disconnected' as const), mode: this.mode, }, } satisfies Datafile; @@ -468,12 +469,13 @@ export class Controller implements ControllerInterface { return this.resolveDataForBuildStep(); } - const result = await this.cache.resolve(this.getCacheReadPolicy()); + const result = await this.cache.resolve(this.cacheReadPolicy); if (result) return result; + return this.resolveDataWithFallbacks(); } - private getCacheReadPolicy(): CacheReadPolicy { + private get cacheReadPolicy(): CacheReadPolicy { if (this.state === 'vercel') { return { getStatus: this.headerSource.getStatusCheck(), @@ -481,17 +483,16 @@ export class Controller implements ControllerInterface { }; } - // Stream/poll maintain their existing schedules; reads do not trigger I/O. - return { getStatus: this.getBackgroundSourceStatus }; - } + if (this.state === 'streaming') { + return { getStatus: this.streamSource.getStatus }; + } - private getBackgroundSourceStatus = (data: Pick) => { - if (this.isConnected) return this.streamSource.getStatus(data); if (this.state === 'polling' || this.state === 'initializing:polling') { - return this.pollingSource.getStatus(data); + return { getStatus: this.pollingSource.getStatus }; } - return Freshness.Unknown; - }; + + return { getStatus: () => 'unknown' }; + } // --------------------------------------------------------------------------- // Stream initialization diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index baa688424..60ee20840 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -1,6 +1,6 @@ import type { DatafileInput } from '../types'; import type { Auth } from './auth'; -import { type CacheMetadata, Freshness } from './datafile-cache'; +import type { CacheMetadata, Freshness } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { TypedEmitter } from './typed-emitter'; @@ -33,7 +33,7 @@ export class PollingSource extends TypedEmitter { } getStatus = ({ ageMs }: Pick): Freshness => - ageMs <= this.config.polling.intervalMs ? Freshness.Fresh : Freshness.Stale; + ageMs <= this.config.polling.intervalMs ? 'fresh' : 'stale'; /** * Perform a single poll request. diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index eaaf06527..624c66fd1 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -1,5 +1,5 @@ import type { DatafileInput } from '../types'; -import { type CacheMetadata, Freshness } from './datafile-cache'; +import type { CacheMetadata, Freshness } from './datafile-cache'; import type { NormalizedOptions } from './normalized-options'; import { connectStream, type PrimedMessage } from './stream-connection'; import { TypedEmitter } from './typed-emitter'; @@ -30,7 +30,7 @@ export class StreamSource extends TypedEmitter { } getStatus = ({ ageMs }: Pick): Freshness => - ageMs <= 30_000 ? Freshness.Fresh : Freshness.Stale; + ageMs <= 30_000 ? 'fresh' : 'stale'; /** * Start the stream connection. From bb2362a80ca22f389f4e5ad761eac124828557c5 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 13:31:56 +0200 Subject: [PATCH 13/41] test(flags-core): guard snapshot freshness and cold-cache recovery --- .../src/vercel-mode.black-box.test.ts | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index f7db8fcaa..1dd545025 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -234,12 +234,18 @@ describe('Vercel mode (black-box)', () => { expect((await instance.evaluate('feature')).value).toBe(true); }); - it('recovers on a later read when the cold-cache fetch fails', async () => { - const instance = client({ datafile: undefined }); + it.each([ + undefined, + 0, + 0.01, + ])('recovers on a later read when the cold-cache fetch fails with staleIfError=%s', async (staleIfError) => { + const instance = client({ datafile: undefined, staleIfError }); dataFetch.mockResolvedValueOnce(new Response(null, { status: 503 })); await expect(instance.evaluate('feature')).rejects.toThrow( 'Failed to fetch data', ); + expect(dataFetch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(11); mockDatafileResponse(TIMESTAMP, true); expect(await instance.evaluate('feature')).toMatchObject({ @@ -330,6 +336,71 @@ describe('Vercel mode (black-box)', () => { ).toBe(true); }); + it('does not renew freshness from a matching header on a snapshot read', async () => { + const input = { ...datafile(), fetchedAt: TIMESTAMP }; + const instance = client({ datafile: input }); + await instance.initialize(); + vi.setSystemTime(TIMESTAMP + 11_000); + setVersion(TIMESTAMP); + const snapshot = await instance.getDatafile(); + expect(snapshot).toEqual({ + ...input, + metrics: { + readMs: 0, + source: 'in-memory', + cacheStatus: 'STALE', + connectionState: 'disconnected', + mode: 'vercel', + }, + }); + expect(snapshot.definitions).toBe(input.definitions); + expect(dataFetch).not.toHaveBeenCalled(); + + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + + it('does not observe newer snapshot headers when assessing later matching evaluations', async () => { + const input = { ...datafile(), fetchedAt: TIMESTAMP }; + const instance = client({ datafile: input }); + await instance.initialize(); + vi.setSystemTime(TIMESTAMP + 9_000); + setVersion(TIMESTAMP + 1); + const snapshot = await instance.getDatafile(); + expect(snapshot).toEqual({ + ...input, + metrics: { + readMs: 0, + source: 'in-memory', + cacheStatus: 'STALE', + connectionState: 'disconnected', + mode: 'vercel', + }, + }); + expect(dataFetch).not.toHaveBeenCalled(); + + setVersion(TIMESTAMP); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'HIT' }, + }); + vi.setSystemTime(TIMESTAMP + 10_001); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); + expect(dataFetch).toHaveBeenCalledTimes(1); + }); + it.each([ undefined, 'flags_other=1700000000000', From 4a50da0067e1bd2354da78bcfeafac020e386834 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 14:55:10 +0200 Subject: [PATCH 14/41] test(flags-core): retain seconds-based stale-if-error coverage --- packages/vercel-flags-core/CLAUDE.md | 2 +- .../vercel-flags-core/src/vercel-mode.black-box.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 4876832db..37dc7775a 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -136,7 +136,7 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu - A newer header permits background refresh within `staleWhileRevalidate` seconds of the latest accepted fetch or valid confirmation; unknown/expired cache age blocks for refresh. - Every returned entry passes through `DatafileCache.read()`. Refresh errors use its - `staleIfErrorMs` allowance; expiry forces blocking recovery on the next newer-header read. + `staleIfError` allowance; expiry forces blocking recovery on the next newer-header read. - Missing/malformed headers use cached data without fetching, subject to stale-if-error. - `getDatafile()` remains a snapshot read: it enforces the same failure policy but does not inspect request headers. Disabling both stream and polling selects offline mode. diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 1dd545025..a7e8624c1 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -593,7 +593,7 @@ describe('Vercel mode (black-box)', () => { dataFetch.mockResolvedValueOnce( new Response(null, { status: 503, statusText: 'Service Unavailable' }), ); - const instance = client({ staleIfErrorMs: 0 }); + const instance = client({ staleIfError: 0 }); const failed = await instance.evaluate('feature', false); expect(failed.value).toBe(false); @@ -1089,7 +1089,7 @@ describe('Vercel mode (black-box)', () => { }); it('shares the first-error deadline with snapshots and recovers after expiry', async () => { - const instance = client({ staleIfErrorMs: 1_000 }); + const instance = client({ staleIfError: 1 }); setVersion(TIMESTAMP + 1); const firstError = new Error('first failure'); dataFetch.mockRejectedValueOnce(firstError); @@ -1129,7 +1129,7 @@ describe('Vercel mode (black-box)', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const instance = client({ datafile: { ...datafile(), fetchedAt: TIMESTAMP }, - staleIfErrorMs: 0, + staleIfError: 0, }); setVersion(TIMESTAMP + 1); const failure = new Error('background failure'); From 598a7bc04e835ecb94669a751f2e5d4182d1c603 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 15:07:48 +0200 Subject: [PATCH 15/41] docs(flags-core): explain cache serving and recovery checks --- packages/vercel-flags-core/src/controller/datafile-cache.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index a3eb96371..16fa1dbbf 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -177,9 +177,13 @@ export class DatafileCache { if (metadata) { const status = policy.getStatus(metadata); if (status === 'fresh' || status === 'unknown' || !policy.fetch) { + // No on-read refresh is requested or available here. Even a fresh + // assessment must pass read()'s stale-if-error check before serving. return [this.read()!, status === 'fresh' ? 'HIT' : 'STALE']; } + // If stale-if-error has expired, fall through to a blocking recovery fetch. + // Calling read() here would throw before a background fetch could start. if (status === 'stale' && this.canServe()) { const stale = this.read()!; this.fetchInBackground(policy.fetch); From b751742171c2b434f359e5f820941ed75c8d6eda Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 15:11:46 +0200 Subject: [PATCH 16/41] docs(flags-core): clarify cache and source policy decisions --- packages/prepare-flags-definitions/src/index.ts | 1 + .../src/controller/datafile-cache.ts | 12 +++++++++--- .../src/controller/header-source.ts | 3 ++- packages/vercel-flags-core/src/controller/index.ts | 11 ++++++----- .../src/controller/stream-connection.ts | 2 ++ .../vercel-flags-core/src/controller/tagged-data.ts | 2 +- 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/prepare-flags-definitions/src/index.ts b/packages/prepare-flags-definitions/src/index.ts index 012f89001..f0d70f51a 100644 --- a/packages/prepare-flags-definitions/src/index.ts +++ b/packages/prepare-flags-definitions/src/index.ts @@ -200,6 +200,7 @@ async function fetchDatafile( if (res.ok) { const definitions = (await res.json()) as BundledDefinitions; + // Preserve fetch time so loading the bundle does not make old data fresh. return { ...definitions, fetchedAt: Date.now() }; } diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 16fa1dbbf..71d4114ac 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -36,6 +36,7 @@ function parseConfigUpdatedAt(value: unknown): number | undefined { /** Storage, serving policy, and fetching driven by source callbacks. */ export class DatafileCache { private data: TaggedData | undefined; + // Confirmations refresh age without rewriting the datafile's persisted fetch time. private freshAt: number | undefined; private failure: { error: Error; startedAt: number } | undefined; @@ -47,6 +48,7 @@ export class DatafileCache { private readonly waitUntil: WaitUntil = () => {}, ) {} + /** Expired data still exists; fallback loading must not bypass its failure policy. */ get hasData(): boolean { return this.data !== undefined; } @@ -153,6 +155,7 @@ export class DatafileCache { } fail(error: Error): void { + // Repeated failures must not keep extending the stale-if-error allowance. this.failure ??= { error, startedAt: Date.now() }; } @@ -172,13 +175,13 @@ export class DatafileCache { } async resolve(policy: CacheReadPolicy): Promise { - // Expired entries can still recover through confirmation or a blocking fetch. const metadata = this.metadata; if (metadata) { + // The assessment may confirm recovery, so run it before read() checks failure. const status = policy.getStatus(metadata); if (status === 'fresh' || status === 'unknown' || !policy.fetch) { - // No on-read refresh is requested or available here. Even a fresh - // assessment must pass read()'s stale-if-error check before serving. + // Stream/poll omit fetch because they maintain the cache independently. + // read() still enforces stale-if-error, even for a fresh assessment. return [this.read()!, status === 'fresh' ? 'HIT' : 'STALE']; } @@ -206,6 +209,7 @@ export class DatafileCache { // A cold fetch discovers the project; assess the original request's header. if (!metadata && this.metadata) policy.getStatus(this.metadata); + // Serve the accepted cache entry; the response may have contained older data. const data = this.read(); if (!data) throw new Error('@vercel/flags-core: Fetch returned no definitions'); @@ -214,6 +218,7 @@ export class DatafileCache { private startFetch(fetch: Fetch) { const { signal } = this.abortController; + // Share the fetch, but let each caller assess its own request's headers. if (this.fetching) return { promise: this.fetching, signal }; const promise = Promise.resolve() @@ -252,6 +257,7 @@ export class DatafileCache { } } + /** Clearing storage is not recovery; restored seeds keep the failure deadline. */ clear(): void { this.abortController.abort(); this.abortController = new AbortController(); diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index fbfc61976..308715249 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -18,7 +18,7 @@ export class HeaderSource extends TypedEmitter { super(); } - /** Capture this request's header before any cold-cache fetch awaits. */ + /** Capture the header now so a shared fetch cannot switch the request being assessed. */ getStatusCheck(): CacheReadPolicy['getStatus'] { const { headers } = getRequestContext(); const header = @@ -39,6 +39,7 @@ export class HeaderSource extends TypedEmitter { this.emit('confirmed', data); } + // This request is satisfied; only confirmation above can renew age or clear failure. if (headerTs <= currentTs) return 'fresh'; const { staleWhileRevalidateMs } = this.options; diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index fa2651311..82480d941 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -158,13 +158,13 @@ export class Controller implements ControllerInterface { private onStreamPrimed = (message: PrimedMessage) => { this.unauthorized = false; this.cache.tryConfirm(message, 'revision'); - // The server confirmed our revision is current — no new data needed. - // Transition to streaming like a normal connected event. + // The stream is connected even if its revision no longer matches the cache. if (this.state === 'degraded' || this.state === 'initializing:stream') { this.transition('streaming'); } }; private onStreamPing = () => { + // Pings refresh age but do not prove recovery from a recorded failure. this.cache.resetAge(); }; private onStreamConnected = () => { @@ -283,6 +283,7 @@ export class Controller implements ControllerInterface { } } + // Select header mode after hydration so provided/bundled data avoids a cold fetch. if (this.headerSource.isAvailable()) { this.transition('vercel'); return; @@ -453,7 +454,7 @@ export class Controller implements ControllerInterface { } // --------------------------------------------------------------------------- - // Data resolution (shared by read() and getDatafile()) + // Data resolution // --------------------------------------------------------------------------- /** @@ -461,8 +462,7 @@ export class Controller implements ControllerInterface { * current mode. Returns tagged data and cache status. * * Build step: cached → bundled → one-time fetch - * Runtime with cache: return cached data - * Runtime without cache: stream/poll → datafile → bundled → fetch → throw + * Runtime: source policy chooses cached data or refresh; fall back if empty. */ private async resolveData(): Promise<[TaggedData, Metrics['cacheStatus']]> { if (this.options.buildStep) { @@ -487,6 +487,7 @@ export class Controller implements ControllerInterface { return { getStatus: this.streamSource.getStatus }; } + // Seeded initialization can leave the active poller in 'initializing:polling'. if (this.state === 'polling' || this.state === 'initializing:polling') { return { getStatus: this.pollingSource.getStatus }; } diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 276a65939..8c61928f3 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -78,6 +78,7 @@ export async function connectStream( let lastAttemptTime = 0; const reportError = (error: unknown): void => { + // Deliberate shutdown must not start a stale-if-error deadline. if (abortController.signal.aborted) return; onError?.( error instanceof Error @@ -278,6 +279,7 @@ export async function connectStream( if (abortController.signal.aborted) { break; } + // Ping timeouts report failure through onDisconnect below, not an abort error. if (!connectionAbort.signal.aborted) { reportError(error); } diff --git a/packages/vercel-flags-core/src/controller/tagged-data.ts b/packages/vercel-flags-core/src/controller/tagged-data.ts index f87824c22..61e6a0e5f 100644 --- a/packages/vercel-flags-core/src/controller/tagged-data.ts +++ b/packages/vercel-flags-core/src/controller/tagged-data.ts @@ -15,7 +15,7 @@ export type TaggedData = DatafileInput & { }; /** - * Tags a DatafileInput with metadata. + * Stamp live arrivals; reusing provided/bundled data must preserve its original age. */ export function tagData(data: DatafileInput, origin: DataOrigin): TaggedData { const tagged: TaggedData = { ...data, _origin: origin }; From 9d0de336833e703ab70dec3611c5e824c200db48 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 15:18:16 +0200 Subject: [PATCH 17/41] feat(flags-core): fall back to stream or polling without version headers --- .changeset/header-driven-vercel-mode.md | 2 + packages/vercel-flags-core/CLAUDE.md | 6 +- packages/vercel-flags-core/README.md | 7 +- .../src/controller/datafile-cache.ts | 9 +- .../src/controller/header-source.ts | 17 +- .../vercel-flags-core/src/controller/index.ts | 49 +++- .../src/controller/normalized-options.ts | 1 + .../src/vercel-mode.black-box.test.ts | 276 ++++++++++++++++-- 8 files changed, 329 insertions(+), 38 deletions(-) diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index 745bc9ff2..e61301cff 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -4,6 +4,8 @@ Add a header-driven `vercel` client mode, enabled by default when `VERCEL=1`. Initialization loads provided/bundled definitions; request versions trigger refreshes and an empty cache fetches on its first read. Explicit offline/build behavior is preserved. +An evaluation with a missing or empty version header permanently starts streaming when enabled, otherwise polling. Concurrent reads share startup, and pending header refreshes are cancelled without losing cached data or resetting stale-if-error. Initialization and snapshot reads do not trigger this switch. + The controller supplies a source freshness-status callback and optional fetch callback to the cache. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Polling becomes stale after its interval, streaming after 30 seconds; stream pings reset age without clearing or extending a stale-if-error failure. Source schedules stay unchanged. Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 37dc7775a..8de6591f4 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -137,7 +137,11 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu latest accepted fetch or valid confirmation; unknown/expired cache age blocks for refresh. - Every returned entry passes through `DatafileCache.read()`. Refresh errors use its `staleIfError` allowance; expiry forces blocking recovery on the next newer-header read. -- Missing/malformed headers use cached data without fetching, subject to stale-if-error. +- Evaluations without a version header (including an empty header) permanently + start streaming if enabled, otherwise polling, using the existing startup timeouts. + Concurrent reads share source startup. Pending header fetches are cancelled without + clearing stored data or the failure deadline; their readers resume through the new source. +- Present malformed/unrelated headers use cached data without fetching, subject to stale-if-error. - `getDatafile()` remains a snapshot read: it enforces the same failure policy but does not inspect request headers. Disabling both stream and polling selects offline mode. diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index 8067da359..56782e6f5 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -37,8 +37,11 @@ Outside Vercel, pass an SDK key explicitly: `createClient(process.env.FLAGS)`. When `VERCEL=1`, the client defaults to `vercel: true`. Initialization loads provided or bundled definitions without starting a stream or polling. Request version headers -indicate when cached definitions need refreshing; reads without a usable header keep -cached definitions, fetching only when the cache is empty. +indicate when cached definitions need refreshing. If an evaluation has no version +header (or an empty one), the client permanently switches to streaming when enabled, +otherwise polling. Concurrent evaluations share that startup and later headers do +not switch the client back. A present but malformed or unrelated header keeps the +existing cached-read behavior, fetching only when the cache is empty. ```ts const client = createClient(process.env.FLAGS!, { diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 71d4114ac..04a2e7b46 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -257,11 +257,16 @@ export class DatafileCache { } } - /** Clearing storage is not recovery; restored seeds keep the failure deadline. */ - clear(): void { + /** Switching sources cancels revalidation without changing storage or failure. */ + cancelFetch(): void { this.abortController.abort(); this.abortController = new AbortController(); this.fetching = undefined; + } + + /** Clearing storage is not recovery; restored seeds keep the failure deadline. */ + clear(): void { + this.cancelFetch(); this.data = undefined; this.freshAt = undefined; } diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 308715249..e8a19aa06 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -18,12 +18,21 @@ export class HeaderSource extends TypedEmitter { super(); } - /** Capture the header now so a shared fetch cannot switch the request being assessed. */ - getStatusCheck(): CacheReadPolicy['getStatus'] { + private getVersionHeader(): string | undefined { const { headers } = getRequestContext(); - const header = + return ( headers?.['x-vercel-flags-config-versions'] ?? - headers?.['flags-config-versions']; + headers?.['flags-config-versions'] + ); + } + + hasVersionHeader(): boolean { + return Boolean(this.getVersionHeader()); + } + + /** Capture the header now so a shared fetch cannot switch the request being assessed. */ + getStatusCheck(): CacheReadPolicy['getStatus'] { + const header = this.getVersionHeader(); return (data) => { const headerTs = this.getUpdatedAtHeader(data.projectId, header); diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 82480d941..30e79aceb 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -79,6 +79,7 @@ type State = * **Runtime — Vercel mode** (vercel enabled, with stream or polling enabled): * - Loads provided/bundled data before selecting the mode; no startup network * - HeaderSource checks request versions and refreshes when needed + * - An evaluation without a version header permanently starts stream/poll * - Cache applies version acceptance and stale-if-error to all served data * * **Runtime — offline mode** (neither stream nor polling): @@ -105,6 +106,8 @@ export class Controller implements ControllerInterface { private pollingSource: PollingSource; private bundledSource: BundledSource; private headerSource: HeaderSource; + // Retain the startup promise so fallback stays permanent and concurrent reads share it. + private headerFallback: Promise | undefined; // Usage tracking private usageTracker: UsageTracker; @@ -284,7 +287,7 @@ export class Controller implements ControllerInterface { } // Select header mode after hydration so provided/bundled data avoids a cold fetch. - if (this.headerSource.isAvailable()) { + if (this.headerSource.isAvailable() && !this.headerFallback) { this.transition('vercel'); return; } @@ -469,12 +472,48 @@ export class Controller implements ControllerInterface { return this.resolveDataForBuildStep(); } - const result = await this.cache.resolve(this.cacheReadPolicy); - if (result) return result; + if (this.state === 'vercel' && !this.headerSource.hasVersionHeader()) { + this.headerFallback = this.initializeHeaderFallback(); + } + + if (this.headerFallback) { + await this.headerFallback; + if (this.state === 'shutdown') { + throw new Error('@vercel/flags-core: Client is shut down'); + } + } + + const usingHeaders = this.state === 'vercel'; + try { + const result = await this.cache.resolve(this.cacheReadPolicy); + if (result) return result; + } catch (error) { + // A concurrent read may have switched sources and cancelled this header fetch. + if (usingHeaders && this.headerFallback) return this.resolveData(); + throw error; + } return this.resolveDataWithFallbacks(); } + private async initializeHeaderFallback(): Promise { + this.cache.cancelFetch(); + this.headerSource.stop(); + + if (this.options.stream.enabled) { + this.transition('initializing:stream'); + await this.tryInitializeStream(); + return; + } + + this.transition('initializing:polling'); + await this.tryInitializePolling(); + if (this.state === 'shutdown') return; + // A timed-out first poll must not leave the fallback without future updates. + this.pollingSource.startInterval(); + this.transition('polling'); + } + private get cacheReadPolicy(): CacheReadPolicy { if (this.state === 'vercel') { return { @@ -565,7 +604,7 @@ export class Controller implements ControllerInterface { if (this.options.polling.initTimeoutMs <= 0) { try { await pollPromise; - if (this.cache.hasData) { + if (this.state !== 'shutdown' && this.cache.hasData) { this.pollingSource.startInterval(); return true; } @@ -595,7 +634,7 @@ export class Controller implements ControllerInterface { return false; } - if (this.cache.hasData) { + if (this.state !== 'shutdown' && this.cache.hasData) { this.pollingSource.startInterval(); return true; } diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index feb7cf38e..7d7e95dde 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -49,6 +49,7 @@ export type ControllerOptions = { /** * Use request version headers instead of streaming or polling at runtime. * Initialization starts no network activity; reads fetch only when needed. + * A read without a version header permanently falls back to stream/poll. * Disabling both stream and polling still selects offline mode. * @default process.env.VERCEL === '1' */ diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index a7e8624c1..477e60313 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -45,8 +45,25 @@ function deferred() { return { promise, resolve, reject }; } +function mockStream() { + let controller!: ReadableStreamDefaultController; + const body = new ReadableStream({ + start(value) { + controller = value; + }, + }); + return { + response: new Response(body), + push: (message: unknown) => + controller.enqueue( + new TextEncoder().encode(`${JSON.stringify(message)}\n`), + ), + }; +} + const clients = new Set(); const dataFetch = vi.fn(); +const streamFetch = vi.fn(); const transport = vi.fn(); let cleanupContext = () => {}; @@ -86,12 +103,17 @@ beforeEach(() => { }); dataFetch.mockReset(); dataFetch.mockRejectedValue(new Error('Unexpected datafile fetch')); + streamFetch.mockReset(); + streamFetch.mockRejectedValue(new Error('Unexpected stream fetch')); transport.mockReset(); transport.mockImplementation((input, init) => { const url = String(input); if (url === 'https://flags.vercel.com/v1/datafile') { return dataFetch(input, init); } + if (url === 'https://flags.vercel.com/v1/stream') { + return streamFetch(input, init); + } if (url === 'https://flags.vercel.com/v1/ingest') { return Promise.resolve(new Response()); } @@ -167,10 +189,13 @@ describe('Vercel mode (black-box)', () => { }); it.each([ - 'provided', - 'bundled', - 'empty', - ] as const)('uses the %s cache without a header, fetching only when empty', async (cache) => { + ['streaming', 'provided'], + ['streaming', 'bundled'], + ['streaming', 'empty'], + ['polling', 'provided'], + ['polling', 'bundled'], + ['polling', 'empty'], + ] as const)('falls back to %s with a %s cache when no header arrives', async (mode, cache) => { setVersion(undefined); if (cache === 'bundled') { vi.mocked(readBundledDefinitions).mockResolvedValue({ @@ -178,22 +203,221 @@ describe('Vercel mode (black-box)', () => { state: 'ok', }); } - mockDatafileResponse(TIMESTAMP, true); + const stream = mockStream(); + streamFetch.mockResolvedValueOnce(stream.response); + mockDatafileResponse(TIMESTAMP + 1, true); const instance = client({ + stream: mode === 'streaming', datafile: cache === 'provided' ? datafile() : undefined, }); + const reading = instance.evaluate('feature'); + stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 1, true) }); + await vi.advanceTimersByTimeAsync(0); + expect(await reading).toMatchObject({ + value: true, + metrics: { mode, source: 'in-memory', cacheStatus: 'HIT' }, + }); + expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 1 : 0); + expect(readBundledDefinitions).toHaveBeenCalledTimes( + cache === 'provided' ? 0 : 1, + ); + + // Later headers cannot switch the client back or trigger on-read refreshes. + setVersion(TIMESTAMP + 100); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode, cacheStatus: 'HIT' }, + }); + expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 1 : 0); + + if (mode === 'streaming') { + stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 2) }); + } else { + mockDatafileResponse(TIMESTAMP + 2); + await vi.advanceTimersByTimeAsync(30_000); + } + await vi.advanceTimersByTimeAsync(0); expect(await instance.evaluate('feature')).toMatchObject({ - value: cache === 'empty', - metrics: { - mode: 'vercel', - cacheStatus: cache === 'empty' ? 'MISS' : 'STALE', - }, + value: false, + metrics: { mode, cacheStatus: 'HIT' }, }); - expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( - 'STALE', + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 2 : 0); + }); + + it.each([ + 'absent', + 'empty', + 'no context', + ])('shares polling startup after a previously usable header becomes %s', async (header) => { + const instance = client({ stream: false }); + expect((await instance.evaluate('feature')).metrics?.mode).toBe('vercel'); + cleanupContext(); + if (header !== 'no context') { + cleanupContext = setRequestContext( + header === 'empty' ? { [HEADER]: '' } : {}, + ); + } + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const settled = vi.fn(); + const first = instance.evaluate('feature').then(settled); + await vi.advanceTimersByTimeAsync(0); + setVersion(TIMESTAMP + 100); + const second = instance.bulkEvaluate([{ key: 'feature' }]); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + await first; + expect(settled).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + value: true, + metrics: expect.objectContaining({ mode: 'polling' }), + }), ); - expect(dataFetch).toHaveBeenCalledTimes(cache === 'empty' ? 1 : 0); + expect((await second).feature).toMatchObject({ + value: true, + metrics: { mode: 'polling' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(streamFetch).not.toHaveBeenCalled(); + }); + + it.each([ + 'success', + 'failure', + ])('discards a cancelled header refresh ending in %s after polling takes over', async (outcome) => { + const pendingHeader = deferred(); + dataFetch.mockReturnValueOnce(pendingHeader.promise); + const instance = client({ stream: false, staleIfError: 0 }); + setVersion(TIMESTAMP + 1); + const originalRead = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(0); + const signal = dataFetch.mock.calls[0]?.[1]?.signal; + + setVersion(undefined); + mockDatafileResponse(TIMESTAMP + 2, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'polling' }, + }); + expect(signal?.aborted).toBe(true); + if (outcome === 'success') { + pendingHeader.resolve(Response.json(datafile(TIMESTAMP + 3))); + } else { + pendingHeader.reject(new Error('late header failure')); + } + + expect(await originalRead).toMatchObject({ + value: true, + metrics: { mode: 'polling' }, + }); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('preserves the failure deadline through fallback until polling confirms recovery', async () => { + const instance = client({ stream: false, staleIfError: 1 }); + const firstError = new Error('header failure'); + setVersion(TIMESTAMP + 1); + dataFetch.mockRejectedValueOnce(firstError); + expect((await instance.evaluate('feature')).value).toBe(false); + + vi.setSystemTime(TIMESTAMP + 1_001); + setVersion(undefined); + dataFetch.mockRejectedValueOnce(new Error('poll failure')); + await expect(instance.evaluate('feature')).rejects.toBe(firstError); + await expect(instance.getDatafile()).rejects.toBe(firstError); + expect(dataFetch).toHaveBeenCalledTimes(2); + + mockDatafileResponse(TIMESTAMP); + await vi.advanceTimersByTimeAsync(30_000); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(3); + expect(streamFetch).not.toHaveBeenCalled(); + }); + + it('continues polling after the first fallback poll times out', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const firstPoll = deferred(); + dataFetch.mockReturnValueOnce(firstPoll.promise); + const instance = client({ stream: false, disableMetrics: true }); + setVersion(undefined); + const reading = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(3_000); + expect(await reading).toMatchObject({ + value: false, + metrics: { mode: 'polling', cacheStatus: 'STALE' }, + }); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ); + + mockDatafileResponse(TIMESTAMP + 2, true); + await vi.advanceTimersByTimeAsync(30_000); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + firstPoll.resolve(Response.json(datafile(TIMESTAMP + 1))); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it('continues connecting after fallback stream initialization times out', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const stream = mockStream(); + streamFetch.mockResolvedValueOnce(stream.response); + const instance = client(); + setVersion(undefined); + const reading = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(3_000); + expect(await reading).toMatchObject({ + value: false, + metrics: { cacheStatus: 'STALE' }, + }); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ); + + stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 1, true) }); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + }); + expect(streamFetch).toHaveBeenCalledTimes(1); + expect(dataFetch).not.toHaveBeenCalled(); + }); + + it.each([ + 0, 3_000, + ])('does not start a polling interval after shutdown with timeout %i', async (initTimeoutMs) => { + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client({ + stream: false, + polling: { intervalMs: 30_000, initTimeoutMs }, + disableMetrics: true, + }); + setVersion(undefined); + const reading = instance.evaluate('feature'); + const rejection = expect(reading).rejects.toThrow('Client is shut down'); + await vi.advanceTimersByTimeAsync(0); + await instance.shutdown(); + clients.delete(instance); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + await rejection; + await vi.advanceTimersByTimeAsync(60_000); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); }); it('retains the original cold request header when the request context changes during a fetch', async () => { @@ -436,8 +660,11 @@ describe('Vercel mode (black-box)', () => { expect(dataFetch).toHaveBeenCalledTimes(mode === 'vercel' ? 1 : 0); }); - it('does not enable runtime header refresh during a build', async () => { - setVersion(TIMESTAMP + 20_000); + it.each([ + undefined, + TIMESTAMP + 20_000, + ])('does not enable runtime sources during a build with header %s', async (version) => { + setVersion(version); const instance = client({ buildStep: true }); const result = await instance.evaluate('feature'); @@ -445,6 +672,7 @@ describe('Vercel mode (black-box)', () => { expect(result.value).toBe(false); expect(result.metrics?.mode).toBe('build'); expect(dataFetch).not.toHaveBeenCalled(); + expect(streamFetch).not.toHaveBeenCalled(); }); it.each([ @@ -528,7 +756,7 @@ describe('Vercel mode (black-box)', () => { }); it('rechecks new request versions instead of caching the first HIT forever', async () => { - const instance = client(); + const instance = client({ stream: false }); expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( 'HIT', ); @@ -553,10 +781,12 @@ describe('Vercel mode (black-box)', () => { expect(dataFetch).toHaveBeenCalledTimes(2); setVersion(); - expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( - 'STALE', - ); - expect(dataFetch).toHaveBeenCalledTimes(2); + mockDatafileResponse(TIMESTAMP + 60_000, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(3); }); it('does not make a fresh request wait on another requests blocking refresh', async () => { @@ -842,7 +1072,6 @@ describe('Vercel mode (black-box)', () => { it.each([ ['older', TIMESTAMP - 1], - ['missing', undefined], ['malformed', 'invalid'], ['newer', TIMESTAMP + 1], ] as const)('does not renew freshness for %s headers', async (_kind, version) => { @@ -1103,8 +1332,8 @@ describe('Vercel mode (black-box)', () => { expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); vi.setSystemTime(TIMESTAMP + 1_001); - // Older/missing evidence must not reset the first-error deadline or fetch. - for (const version of [undefined, 'invalid', TIMESTAMP - 1, TIMESTAMP]) { + // Older/malformed evidence must not reset the first-error deadline or fetch. + for (const version of ['invalid', TIMESTAMP - 1, TIMESTAMP]) { setVersion(version); await expect(instance.evaluate('feature')).rejects.toBe(firstError); await expect(instance.getDatafile()).rejects.toBe(firstError); @@ -1172,7 +1401,6 @@ describe('Vercel mode (black-box)', () => { }); it.each([ - '', 'flags_other=123', `flags_${PROJECT_ID}=0`, `flags_${PROJECT_ID}=-1`, From 62ece88a986e23affb30d7f5904cfee98395b82c Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 15:27:56 +0200 Subject: [PATCH 18/41] fix(flags-core): confirm cache recovery on stream pings --- .changeset/header-driven-vercel-mode.md | 2 +- packages/vercel-flags-core/CLAUDE.md | 7 ++-- packages/vercel-flags-core/README.md | 7 ++-- .../vercel-flags-core/src/controller/index.ts | 4 +- .../src/stream-stale-if-error.test.ts | 40 ++++++++++++++----- 5 files changed, 41 insertions(+), 19 deletions(-) diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index e61301cff..a14ad8a3c 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -6,6 +6,6 @@ Add a header-driven `vercel` client mode, enabled by default when `VERCEL=1`. In An evaluation with a missing or empty version header permanently starts streaming when enabled, otherwise polling. Concurrent reads share startup, and pending header refreshes are cancelled without losing cached data or resetting stale-if-error. Initialization and snapshot reads do not trigger this switch. -The controller supplies a source freshness-status callback and optional fetch callback to the cache. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Polling becomes stale after its interval, streaming after 30 seconds; stream pings reset age without clearing or extending a stale-if-error failure. Source schedules stay unchanged. +The controller supplies a source freshness-status callback and optional fetch callback to the cache. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Polling becomes stale after its interval, streaming after 30 seconds; stream pings reset age and clear stale-if-error failures because each connection sends `primed` or a datafile first. Source schedules stay unchanged. Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 8de6591f4..f93914115 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -265,7 +265,7 @@ When updating tests for new behavior, preserve the strength of existing assertio ### Stream Connection - Uses fetch with streaming body (NDJSON format) -- Callbacks: `onDatafile` (new data), `onPrimed` (server confirmed revision is current), `onPing` (age-only reset), `onDisconnect`, and `onError` (failure evidence for cache policy) +- Callbacks: `onDatafile` (new data), `onPrimed` (server confirmed revision is current), `onPing` (resets age and clears failure), `onDisconnect`, and `onError` (failure evidence for cache policy) - Sends `X-Revision` header with the current revision number on every connection (including reconnects), allowing the server to respond with a lightweight `primed` message instead of a full datafile when the revision is current - The `primed` message confirms the client's data is up-to-date; it resolves the init promise (like `datafile`) but does not update data — resets cache age and clears a failure when revision/identity match, then transitions state to `streaming` - Reconnects with exponential backoff (base: 1s, max: 60s, max retries: 15) @@ -338,7 +338,7 @@ boundary. `hasData` and `revision` expose coordination metadata even after expir so retained data is not replaced by fallback and stream reconnects can still send `X-Revision`. `seed()` never clears failure. Accepted source updates or valid version/revision confirmations clear it; repeated errors/disconnects do not renew -the first-error deadline. Stream opening/pings and initialization timeout alone +the first-error deadline. Stream opening and initialization timeout alone are not recovery/failure evidence respectively. `cache.resolve(policy)` receives a mode-specific `getStatus` callback returning @@ -349,7 +349,8 @@ forwarded through controller event wiring. Stream/poll modes omit on-read revali and retain their existing schedules. New public time windows use seconds; internal normalized durations, cache age, and `fetchedAt` use milliseconds. Polling is fresh through its interval; streaming through 30 seconds. Accepted updates, valid confirmations, -and stream pings reset cache age. Pings preserve any failure and its original deadline. +and stream pings reset cache age. Pings also clear failures: each connection sends +`primed` or a datafile before pings, so they confirm recovery without rewriting `fetchedAt`. Polling errors use the shared source-error handler without logging each failed poll. ### Evaluation Safety diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index 56782e6f5..119c056ce 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -84,8 +84,9 @@ The allowance starts at the first consecutive failure. Repeated errors, disconnects, and provided or bundled fallback data do not renew it. An accepted source update, or a finite equal version for the same project and environment, clears the outage. A stream `primed` message also clears it when its finite numeric -revision and identity match the cached entry. Opening a connection or receiving -a ping alone does not clear a failure. A later failure starts a new allowance. +revision and identity match the cached entry. Pings clear failures too: the server +sends `primed` or a datafile before pings on each connection. Opening a connection +alone does not clear a failure. A later failure starts a new allowance. Responses are observed in completion order, with existing version acceptance. After expiry, `evaluate()` returns the caller's default with reason `error`, or @@ -98,7 +99,7 @@ exists. `getFallbackDatafile()` remains an independent bundled-data export. Polling data is marked stale after the polling interval; streaming data after 30 seconds. Accepted updates and valid confirmations reset cache age without rewriting -`fetchedAt`. Stream pings also reset age, while preserving any failure and its deadline. +`fetchedAt`. Stream pings also reset age and clear any failure. Age alone does not prevent stream/poll reads or trigger extra requests. Source scheduling, retries, timeouts, and build/offline behavior remain unchanged. Poll errors feed the shared failure handler without logging each failed poll. An initialization timeout diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 30e79aceb..0cf3e3ab0 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -167,8 +167,8 @@ export class Controller implements ControllerInterface { } }; private onStreamPing = () => { - // Pings refresh age but do not prove recovery from a recorded failure. - this.cache.resetAge(); + // Each connection sends primed/datafile before pings, so a ping confirms recovery. + this.cache.confirm(); }; private onStreamConnected = () => { if (this.state === 'degraded' || this.state === 'initializing:stream') { diff --git a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts index 04e9305f2..5759c7e75 100644 --- a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts @@ -227,8 +227,9 @@ describe('stream stale-if-error through the public API', () => { expectRequests(['0', '1', '2', '3', '4', '5', '6', '7']); }); - it('keeps the inclusive first-error deadline through retries, HTTP open, pings, and connected events', async () => { + it('keeps the first-error deadline through retries and unconfirmed messages, then recovers on ping', async () => { const { instance, stream } = await start({ staleIfError: 3 }); + const snapshot = await instance.getDatafile(); const first = new Error('first stream read failed'); const repeated = new Error('reconnect failed'); const reconnect = mockStream(); @@ -246,11 +247,9 @@ describe('stream stale-if-error through the public API', () => { expectRequests(['0', '1']); await vi.advanceTimersByTimeAsync(1); expectRequests(['0', '1', '2']); - reconnect.push({ type: 'ping' }); // These messages emit connected but neither confirms the cached snapshot. reconnect.push(primed({ revision: 6 })); reconnect.push({ type: 'datafile', data: data({ configUpdatedAt: 9 }) }); - reconnect.push({ type: 'ping' }); await vi.advanceTimersByTimeAsync(1_000); expect(await instance.evaluate('flagA')).toMatchObject({ value: true, @@ -263,9 +262,6 @@ describe('stream stale-if-error through the public API', () => { expect((await instance.getDatafile()).configUpdatedAt).toBe(10); await vi.advanceTimersByTimeAsync(1); await expectExpired(instance, first); - reconnect.push({ type: 'ping' }); - await vi.advanceTimersByTimeAsync(0); - await expectExpired(instance, first); const fallback = { value: false, variantId: null, @@ -282,6 +278,16 @@ describe('stream stale-if-error through the public API', () => { flagA: fallback, missing: { ...fallback, value: undefined }, }); + reconnect.push({ type: 'ping' }); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('flagA')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'HIT' }, + }); + const recovered = await instance.getDatafile(); + expect(recovered).toEqual(snapshot); + expect(recovered.definitions).toBe(snapshot.definitions); + expect(recovered.fetchedAt).toBe(0); expectRequests(['0', '1', '2']); }); @@ -565,7 +571,7 @@ describe('stream stale-if-error through the public API', () => { expectRequests(['0', '1']); }); - it('starts SIE only at ping timeout and keeps the original failure through another ping timeout', async () => { + it('starts a new SIE allowance when a recovered stream times out again', async () => { const { instance } = await start({ staleIfError: 0.1 }); const reconnect = mockStream(); const third = mockStream(); @@ -577,7 +583,6 @@ describe('stream stale-if-error through the public API', () => { expectRequests(['0']); await vi.advanceTimersByTimeAsync(2); expectRequests(['0', '1']); - reconnect.push({ type: 'ping' }); await vi.advanceTimersByTimeAsync(99); expect((await instance.evaluate('flagA')).value).toBe(true); await vi.advanceTimersByTimeAsync(1); @@ -587,8 +592,23 @@ describe('stream stale-if-error through the public API', () => { expect(failure).toBeInstanceOf(Error); expect(failure).toMatchObject({ message: 'stream: disconnected' }); await expectExpired(instance, failure as Error); - await vi.advanceTimersByTimeAsync(89_901); - await expectExpired(instance, failure as Error); + + reconnect.push(primed()); + reconnect.push({ type: 'ping' }); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.evaluate('flagA')).value).toBe(true); + await vi.advanceTimersByTimeAsync(90_000); + expect((await instance.evaluate('flagA')).value).toBe(true); + await vi.advanceTimersByTimeAsync(100); + expect((await instance.evaluate('flagA')).value).toBe(true); + await vi.advanceTimersByTimeAsync(1); + const second = await instance + .evaluate('flagA') + .catch((error: unknown) => error); + expect(second).toBeInstanceOf(Error); + expect(second).toMatchObject({ message: 'stream: disconnected' }); + expect(second).not.toBe(failure); + await expectExpired(instance, second as Error); expectRequests(['0', '1', '1']); }); From 393be38f36243a6197b967ad86456fa67aa23a2a Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 23 Sep 2026 16:54:07 +0200 Subject: [PATCH 19/41] refactor(flags-core): share runtime fallback resolution --- packages/vercel-flags-core/CLAUDE.md | 3 + .../vercel-flags-core/src/controller/index.ts | 121 +++++++------- .../src/vercel-mode.black-box.test.ts | 151 +++++++++++++++--- 3 files changed, 203 insertions(+), 72 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index f93914115..5024afb58 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -141,6 +141,9 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu start streaming if enabled, otherwise polling, using the existing startup timeouts. Concurrent reads share source startup. Pending header fetches are cancelled without clearing stored data or the failure deadline; their readers resume through the new source. + Missing-header reads use the shared `resolveDataWithFallbacks()` path. Its pending + promise is cleared on completion; a separate flag keeps header mode disabled. + Source startup falls back to the current cache before provided/bundled definitions. - Present malformed/unrelated headers use cached data without fetching, subject to stale-if-error. - `getDatafile()` remains a snapshot read: it enforces the same failure policy but does not inspect request headers. Disabling both stream and polling selects offline mode. diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 0cf3e3ab0..a9fa00e79 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -106,8 +106,9 @@ export class Controller implements ControllerInterface { private pollingSource: PollingSource; private bundledSource: BundledSource; private headerSource: HeaderSource; - // Retain the startup promise so fallback stays permanent and concurrent reads share it. - private headerFallback: Promise | undefined; + private headerModeDisabled = false; + // Share only pending fallback work; later reads must use the current cache. + private fallbackPromise: Promise | undefined; // Usage tracking private usageTracker: UsageTracker; @@ -287,7 +288,7 @@ export class Controller implements ControllerInterface { } // Select header mode after hydration so provided/bundled data avoids a cold fetch. - if (this.headerSource.isAvailable() && !this.headerFallback) { + if (this.headerSource.isAvailable() && !this.headerModeDisabled) { this.transition('vercel'); return; } @@ -472,48 +473,25 @@ export class Controller implements ControllerInterface { return this.resolveDataForBuildStep(); } - if (this.state === 'vercel' && !this.headerSource.hasVersionHeader()) { - this.headerFallback = this.initializeHeaderFallback(); - } - - if (this.headerFallback) { - await this.headerFallback; - if (this.state === 'shutdown') { - throw new Error('@vercel/flags-core: Client is shut down'); - } + const usingHeaders = this.state === 'vercel'; + if ( + (usingHeaders && !this.headerSource.hasVersionHeader()) || + this.fallbackPromise + ) { + return this.resolveDataWithFallbacks(); } - const usingHeaders = this.state === 'vercel'; try { const result = await this.cache.resolve(this.cacheReadPolicy); if (result) return result; } catch (error) { - // A concurrent read may have switched sources and cancelled this header fetch. - if (usingHeaders && this.headerFallback) return this.resolveData(); - throw error; + // A cancelled header read joins the same fallback path as the switching read. + if (!usingHeaders || !this.headerModeDisabled) throw error; } return this.resolveDataWithFallbacks(); } - private async initializeHeaderFallback(): Promise { - this.cache.cancelFetch(); - this.headerSource.stop(); - - if (this.options.stream.enabled) { - this.transition('initializing:stream'); - await this.tryInitializeStream(); - return; - } - - this.transition('initializing:polling'); - await this.tryInitializePolling(); - if (this.state === 'shutdown') return; - // A timed-out first poll must not leave the fallback without future updates. - this.pollingSource.startInterval(); - this.transition('polling'); - } - private get cacheReadPolicy(): CacheReadPolicy { if (this.state === 'vercel') { return { @@ -776,31 +754,64 @@ export class Controller implements ControllerInterface { ); } - /** - * Retrieves data using the fallback chain (called when no cached data exists). - * Streaming mode: stream → datafile → bundled. - * Polling mode: poll → datafile → bundled. - * Offline mode: datafile → bundled → one-time fetch. - */ + private assertActive(): void { + if (this.state === 'shutdown') { + throw new Error('@vercel/flags-core: Client is shut down'); + } + } + + /** Share startup, then read again so completion never bypasses stale-if-error. */ private async resolveDataWithFallbacks(): Promise< [TaggedData, Metrics['cacheStatus']] > { - // Try the configured primary source + this.assertActive(); + this.fallbackPromise ??= this.loadDataWithFallbacks().finally(() => { + this.fallbackPromise = undefined; + }); + const cacheStatus = await this.fallbackPromise; + this.assertActive(); + return [this.cache.read()!, cacheStatus]; + } + + /** Start stream/poll, then fall back to current cache → provided → bundled. */ + private async loadDataWithFallbacks(): Promise { + const switchingFromHeaders = this.state === 'vercel'; + if (switchingFromHeaders) { + this.headerModeDisabled = true; + this.cache.cancelFetch(); + this.headerSource.stop(); + } else { + // A cancelled header fetch can finish after the new source is already ready. + const cached = await this.cache.resolve(this.cacheReadPolicy); + if (cached) return cached[1]; + } + this.assertActive(); + + let sourceInitialized = false; if (this.options.stream.enabled) { this.transition('initializing:stream'); - const streamSuccess = await this.tryInitializeStream(); - if (streamSuccess && this.cache.hasData) { - this.transition('streaming'); - return [this.cache.read()!, 'MISS']; - } + sourceInitialized = await this.tryInitializeStream(); } else if (this.options.polling.enabled) { this.transition('initializing:polling'); - const pollingSuccess = await this.tryInitializePolling(); - if (pollingSuccess && this.cache.hasData) { - this.transition('polling'); - return [this.cache.read()!, 'MISS']; - } + sourceInitialized = await this.tryInitializePolling(); + } + this.assertActive(); + + if (sourceInitialized && !switchingFromHeaders) { + this.transition(this.options.stream.enabled ? 'streaming' : 'polling'); + } + if (switchingFromHeaders && !this.options.stream.enabled) { + // Header fallback must keep updating even when its first poll times out. + this.pollingSource.startInterval(); + this.transition('polling'); + } + + // Preserve newer cached data on timeout; expired failures must throw before reseeding. + const cached = await this.cache.resolve(this.cacheReadPolicy); + if (cached) { + return sourceInitialized && !switchingFromHeaders ? 'MISS' : cached[1]; } + this.assertActive(); // Fallback chain: datafile → bundled → one-time fetch this.transition('initializing:fallback'); @@ -808,15 +819,16 @@ export class Controller implements ControllerInterface { if (this.options.datafile) { this.cache.seed(tagData(this.options.datafile, 'provided')); this.transition('degraded'); - return [this.cache.read()!, 'STALE']; + return 'STALE'; } const bundled = await this.bundledSource.tryLoad(); + this.assertActive(); if (bundled) { console.warn('@vercel/flags-core: Using bundled definitions as fallback'); this.cache.seed(tagData(bundled, 'bundled')); this.transition('degraded'); - return [this.cache.read()!, 'STALE']; + return 'STALE'; } // Last resort: one-time fetch (only when no stream/poll configured) @@ -831,10 +843,11 @@ export class Controller implements ControllerInterface { } catch { // fetch failed — fall through to throw } + this.assertActive(); if (fetched) { this.cache.seed(tagData(fetched, 'fetched')); this.transition('degraded'); - return [this.cache.read()!, 'MISS']; + return 'MISS'; } } diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 477e60313..3a0bb05a5 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -287,59 +287,174 @@ describe('Vercel mode (black-box)', () => { }); it.each([ - 'success', - 'failure', - ])('discards a cancelled header refresh ending in %s after polling takes over', async (outcome) => { + ['success', 'before'], + ['failure', 'before'], + ['success', 'after'], + ['failure', 'after'], + ] as const)('discards a cancelled header refresh ending in %s %s polling is ready', async (outcome, timing) => { const pendingHeader = deferred(); dataFetch.mockReturnValueOnce(pendingHeader.promise); const instance = client({ stream: false, staleIfError: 0 }); setVersion(TIMESTAMP + 1); - const originalRead = instance.evaluate('feature'); + const settled = vi.fn(); + const originalRead = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); await vi.advanceTimersByTimeAsync(0); const signal = dataFetch.mock.calls[0]?.[1]?.signal; setVersion(undefined); - mockDatafileResponse(TIMESTAMP + 2, true); - expect(await instance.evaluate('feature')).toMatchObject({ - value: true, - metrics: { mode: 'polling' }, - }); + const pendingPoll = deferred(); + dataFetch.mockReturnValueOnce(pendingPoll.promise); + const switchingRead = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(0); expect(signal?.aborted).toBe(true); + expect(dataFetch).toHaveBeenCalledTimes(2); + if (timing === 'after') { + pendingPoll.resolve(Response.json(datafile(TIMESTAMP + 2, true))); + await switchingRead; + } if (outcome === 'success') { pendingHeader.resolve(Response.json(datafile(TIMESTAMP + 3))); } else { pendingHeader.reject(new Error('late header failure')); } + await vi.advanceTimersByTimeAsync(0); + if (timing === 'before') { + expect(settled).not.toHaveBeenCalled(); + pendingPoll.resolve(Response.json(datafile(TIMESTAMP + 2, true))); + } + + for (const result of await Promise.all([originalRead, switchingRead])) { + expect(result).toMatchObject({ + value: true, + metrics: { mode: 'polling' }, + }); + } + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); + expect(dataFetch).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['streaming', 'provided'], + ['streaming', 'bundled'], + ['polling', 'provided'], + ['polling', 'bundled'], + ] as const)('retains newer cached data over %s startup timeout and the original %s seed', async (mode, seed) => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: datafile(), + state: 'ok', + }); + const instance = client({ + stream: mode === 'streaming', + datafile: seed === 'provided' ? datafile() : undefined, + staleWhileRevalidate: 0, + disableMetrics: true, + }); + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + expect((await instance.evaluate('feature')).value).toBe(true); + const snapshot = await instance.getDatafile(); + await vi.advanceTimersByTimeAsync(30_001); - expect(await originalRead).toMatchObject({ + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const stream = mockStream(); + streamFetch.mockResolvedValueOnce(stream.response); + const pendingPoll = deferred(); + dataFetch.mockReturnValueOnce(pendingPoll.promise); + setVersion(undefined); + const reading = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(3_000); + expect(await reading).toMatchObject({ + value: true, + metrics: { source: 'remote', cacheStatus: 'STALE' }, + }); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + mode === 'streaming' + ? '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background' + : '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ); + const retained = await instance.getDatafile(); + expect(retained.configUpdatedAt).toBe(TIMESTAMP + 1); + expect(retained.definitions).toBe(snapshot.definitions); + expect(retained.fetchedAt).toBe(snapshot.fetchedAt); + expect(readBundledDefinitions).toHaveBeenCalledTimes( + seed === 'bundled' ? 1 : 0, + ); + expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 2 : 1); + + // A later source update must replace the cache, not reuse a completed fallback result. + setVersion(TIMESTAMP + 100); + if (mode === 'streaming') { + stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 2) }); + } else { + pendingPoll.resolve(Response.json(datafile(TIMESTAMP + 2))); + } + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { mode, cacheStatus: 'HIT' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 2 : 1); + }); + + it('retries an empty-cache fallback after startup fails', async () => { + const instance = client({ + stream: false, + datafile: undefined, + disableMetrics: true, + }); + setVersion(undefined); + dataFetch.mockRejectedValueOnce(new Error('poll failed')); + await expect(instance.evaluate('feature')).rejects.toThrow( + 'No flag definitions available', + ); + expect(dataFetch).toHaveBeenCalledTimes(1); + + setVersion(TIMESTAMP + 100); + mockDatafileResponse(TIMESTAMP + 1, true); + expect(await instance.evaluate('feature')).toMatchObject({ value: true, metrics: { mode: 'polling' }, }); - expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); expect(dataFetch).toHaveBeenCalledTimes(2); + expect(streamFetch).not.toHaveBeenCalled(); }); it('preserves the failure deadline through fallback until polling confirms recovery', async () => { - const instance = client({ stream: false, staleIfError: 1 }); - const firstError = new Error('header failure'); + const instance = client({ + stream: false, + staleIfError: 1, + staleWhileRevalidate: 0, + }); setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + expect((await instance.evaluate('feature')).value).toBe(true); + const snapshot = await instance.getDatafile(); + const firstError = new Error('header failure'); + setVersion(TIMESTAMP + 2); dataFetch.mockRejectedValueOnce(firstError); - expect((await instance.evaluate('feature')).value).toBe(false); + expect((await instance.evaluate('feature')).value).toBe(true); vi.setSystemTime(TIMESTAMP + 1_001); setVersion(undefined); dataFetch.mockRejectedValueOnce(new Error('poll failure')); await expect(instance.evaluate('feature')).rejects.toBe(firstError); await expect(instance.getDatafile()).rejects.toBe(firstError); - expect(dataFetch).toHaveBeenCalledTimes(2); + expect(dataFetch).toHaveBeenCalledTimes(3); - mockDatafileResponse(TIMESTAMP); + mockDatafileResponse(TIMESTAMP + 1, true); await vi.advanceTimersByTimeAsync(30_000); expect(await instance.evaluate('feature')).toMatchObject({ - value: false, + value: true, metrics: { mode: 'polling', cacheStatus: 'HIT' }, }); - expect(dataFetch).toHaveBeenCalledTimes(3); + const recovered = await instance.getDatafile(); + expect(recovered.definitions).toBe(snapshot.definitions); + expect(recovered.fetchedAt).toBe(snapshot.fetchedAt); + expect(dataFetch).toHaveBeenCalledTimes(4); expect(streamFetch).not.toHaveBeenCalled(); }); From 27a3ea7006d4a9ba41439cca63a0d8f87364906a Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Fri, 25 Sep 2026 12:26:38 +0200 Subject: [PATCH 20/41] refactor(flags-core): keep header fallback on the existing path --- packages/vercel-flags-core/CLAUDE.md | 5 +- .../src/controller/header-source.ts | 6 +- .../vercel-flags-core/src/controller/index.ts | 124 +++++++++--------- .../src/datafile-retries.black-box.test.ts | 5 +- .../src/stale-if-error.test.ts | 5 +- .../src/vercel-mode.black-box.test.ts | 117 +++++++++++------ 6 files changed, 145 insertions(+), 117 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 5024afb58..f8024310b 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -141,9 +141,8 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu start streaming if enabled, otherwise polling, using the existing startup timeouts. Concurrent reads share source startup. Pending header fetches are cancelled without clearing stored data or the failure deadline; their readers resume through the new source. - Missing-header reads use the shared `resolveDataWithFallbacks()` path. Its pending - promise is cleared on completion; a separate flag keeps header mode disabled. - Source startup falls back to the current cache before provided/bundled definitions. + `resolveData()` checks header availability and uses `resolveDataWithFallbacks()` + to start the configured source. Handover retains cached data before considering seeds. - Present malformed/unrelated headers use cached data without fetching, subject to stale-if-error. - `getDatafile()` remains a snapshot read: it enforces the same failure policy but does not inspect request headers. Disabling both stream and polling selects offline mode. diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index e8a19aa06..dabbc481c 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -26,8 +26,8 @@ export class HeaderSource extends TypedEmitter { ); } - hasVersionHeader(): boolean { - return Boolean(this.getVersionHeader()); + isAvailable(): boolean { + return this.isEnabled() && Boolean(this.getVersionHeader()); } /** Capture the header now so a shared fetch cannot switch the request being assessed. */ @@ -78,7 +78,7 @@ export class HeaderSource extends TypedEmitter { this.emit('data', data); }; - isAvailable(): boolean { + isEnabled(): boolean { // Explicit offline mode disables header-driven refreshes too. return ( this.options.vercel && diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index a9fa00e79..47d22085a 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -107,8 +107,9 @@ export class Controller implements ControllerInterface { private bundledSource: BundledSource; private headerSource: HeaderSource; private headerModeDisabled = false; - // Share only pending fallback work; later reads must use the current cache. - private fallbackPromise: Promise | undefined; + private sourceStartup: + | Promise<[TaggedData, Metrics['cacheStatus']]> + | undefined; // Usage tracking private usageTracker: UsageTracker; @@ -288,7 +289,7 @@ export class Controller implements ControllerInterface { } // Select header mode after hydration so provided/bundled data avoids a cold fetch. - if (this.headerSource.isAvailable() && !this.headerModeDisabled) { + if (this.headerSource.isEnabled() && !this.headerModeDisabled) { this.transition('vercel'); return; } @@ -474,20 +475,38 @@ export class Controller implements ControllerInterface { } const usingHeaders = this.state === 'vercel'; - if ( - (usingHeaders && !this.headerSource.hasVersionHeader()) || - this.fallbackPromise - ) { - return this.resolveDataWithFallbacks(); + if (usingHeaders && !this.headerSource.isAvailable()) { + this.headerModeDisabled = true; + this.cache.cancelFetch(); + this.headerSource.stop(); + // The existing fallback chain starts stream/poll; concurrent reads share it. + this.sourceStartup = this.resolveDataWithFallbacks().finally(() => { + this.sourceStartup = undefined; + }); + } + if (this.sourceStartup) { + await this.sourceStartup; + if (this.state === 'shutdown') { + throw new Error('@vercel/flags-core: Client is shut down'); + } } + let result: [TaggedData, Metrics['cacheStatus']] | undefined; try { - const result = await this.cache.resolve(this.cacheReadPolicy); - if (result) return result; + result = await this.cache.resolve(this.cacheReadPolicy); } catch (error) { - // A cancelled header read joins the same fallback path as the switching read. - if (!usingHeaders || !this.headerModeDisabled) throw error; + if ( + !usingHeaders || + !this.headerModeDisabled || + this.state === 'shutdown' + ) { + throw error; + } + // An old header fetch may finish after handover; read from the new source. + if (this.sourceStartup) await this.sourceStartup; + result = await this.cache.resolve(this.cacheReadPolicy); } + if (result) return result; return this.resolveDataWithFallbacks(); } @@ -754,64 +773,41 @@ export class Controller implements ControllerInterface { ); } - private assertActive(): void { - if (this.state === 'shutdown') { - throw new Error('@vercel/flags-core: Client is shut down'); - } - } - - /** Share startup, then read again so completion never bypasses stale-if-error. */ + /** + * Retrieves data when the cache is empty or header mode is unavailable. + * Streaming mode: stream → datafile → bundled. + * Polling mode: poll → datafile → bundled. + * Offline mode: datafile → bundled → one-time fetch. + */ private async resolveDataWithFallbacks(): Promise< [TaggedData, Metrics['cacheStatus']] > { - this.assertActive(); - this.fallbackPromise ??= this.loadDataWithFallbacks().finally(() => { - this.fallbackPromise = undefined; - }); - const cacheStatus = await this.fallbackPromise; - this.assertActive(); - return [this.cache.read()!, cacheStatus]; - } - - /** Start stream/poll, then fall back to current cache → provided → bundled. */ - private async loadDataWithFallbacks(): Promise { const switchingFromHeaders = this.state === 'vercel'; - if (switchingFromHeaders) { - this.headerModeDisabled = true; - this.cache.cancelFetch(); - this.headerSource.stop(); - } else { - // A cancelled header fetch can finish after the new source is already ready. - const cached = await this.cache.resolve(this.cacheReadPolicy); - if (cached) return cached[1]; - } - this.assertActive(); - - let sourceInitialized = false; + // Try the configured primary source if (this.options.stream.enabled) { this.transition('initializing:stream'); - sourceInitialized = await this.tryInitializeStream(); + const streamSuccess = await this.tryInitializeStream(); + if (streamSuccess && this.cache.hasData) { + this.transition('streaming'); + return [this.cache.read()!, 'MISS']; + } } else if (this.options.polling.enabled) { this.transition('initializing:polling'); - sourceInitialized = await this.tryInitializePolling(); - } - this.assertActive(); - - if (sourceInitialized && !switchingFromHeaders) { - this.transition(this.options.stream.enabled ? 'streaming' : 'polling'); - } - if (switchingFromHeaders && !this.options.stream.enabled) { - // Header fallback must keep updating even when its first poll times out. - this.pollingSource.startInterval(); - this.transition('polling'); + const pollingSuccess = await this.tryInitializePolling(); + if (switchingFromHeaders && this.state !== 'shutdown') { + // Missing headers must not leave the client without updates after a timeout. + this.pollingSource.startInterval(); + this.transition('polling'); + } + if (pollingSuccess && this.cache.hasData) { + this.transition('polling'); + return [this.cache.read()!, 'MISS']; + } } - // Preserve newer cached data on timeout; expired failures must throw before reseeding. - const cached = await this.cache.resolve(this.cacheReadPolicy); - if (cached) { - return sourceInitialized && !switchingFromHeaders ? 'MISS' : cached[1]; - } - this.assertActive(); + // Handover can start with newer cached data; do not replace it with the seed. + const cached = this.cache.read(); + if (cached) return [cached, 'STALE']; // Fallback chain: datafile → bundled → one-time fetch this.transition('initializing:fallback'); @@ -819,16 +815,15 @@ export class Controller implements ControllerInterface { if (this.options.datafile) { this.cache.seed(tagData(this.options.datafile, 'provided')); this.transition('degraded'); - return 'STALE'; + return [this.cache.read()!, 'STALE']; } const bundled = await this.bundledSource.tryLoad(); - this.assertActive(); if (bundled) { console.warn('@vercel/flags-core: Using bundled definitions as fallback'); this.cache.seed(tagData(bundled, 'bundled')); this.transition('degraded'); - return 'STALE'; + return [this.cache.read()!, 'STALE']; } // Last resort: one-time fetch (only when no stream/poll configured) @@ -843,11 +838,10 @@ export class Controller implements ControllerInterface { } catch { // fetch failed — fall through to throw } - this.assertActive(); if (fetched) { this.cache.seed(tagData(fetched, 'fetched')); this.transition('degraded'); - return 'MISS'; + return [this.cache.read()!, 'MISS']; } } diff --git a/packages/vercel-flags-core/src/datafile-retries.black-box.test.ts b/packages/vercel-flags-core/src/datafile-retries.black-box.test.ts index 97f7b385f..74bf63d8d 100644 --- a/packages/vercel-flags-core/src/datafile-retries.black-box.test.ts +++ b/packages/vercel-flags-core/src/datafile-retries.black-box.test.ts @@ -133,10 +133,7 @@ describe('datafile retries through the public API', () => { dataFetch.mockReset().mockRejectedValue(failure); await vi.advanceTimersByTimeAsync(30_300); - expect(errorSpy).toHaveBeenCalledExactlyOnceWith( - '@vercel/flags-core: Poll failed:', - failure, - ); + expect(errorSpy).not.toHaveBeenCalled(); expect(dataFetch).toHaveBeenCalledTimes(3); expect((await instance.evaluate('feature')).value).toBe(true); diff --git a/packages/vercel-flags-core/src/stale-if-error.test.ts b/packages/vercel-flags-core/src/stale-if-error.test.ts index 560c03f8e..19af08430 100644 --- a/packages/vercel-flags-core/src/stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stale-if-error.test.ts @@ -141,7 +141,10 @@ describe('polling stale-if-error through the public API', () => { rejectPollOnce(first); poll.mockRejectedValue(repeated); await vi.advanceTimersByTimeAsync(30_300); - expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + }); await vi.advanceTimersByTimeAsync(30_000); expect(await instance.evaluate('flagA')).toEqual({ ...initial, diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 3a0bb05a5..ab55ae902 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -71,6 +71,20 @@ function mockDatafileResponse(timestamp: number, enabled = false) { dataFetch.mockResolvedValueOnce(Response.json(datafile(timestamp, enabled))); } +function rejectDatafileOnce(error: Error) { + for (let attempt = 0; attempt < 3; attempt++) { + dataFetch.mockRejectedValueOnce(error); + } +} + +function mockDatafileHttpFailure(statusText = '') { + for (let attempt = 0; attempt < 3; attempt++) { + dataFetch.mockResolvedValueOnce( + new Response(null, { status: 503, statusText }), + ); + } +} + function setVersion(timestamp?: number | string) { cleanupContext(); cleanupContext = setRequestContext( @@ -407,11 +421,13 @@ describe('Vercel mode (black-box)', () => { disableMetrics: true, }); setVersion(undefined); - dataFetch.mockRejectedValueOnce(new Error('poll failed')); - await expect(instance.evaluate('feature')).rejects.toThrow( + rejectDatafileOnce(new Error('poll failed')); + const failure = expect(instance.evaluate('feature')).rejects.toThrow( 'No flag definitions available', ); - expect(dataFetch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(300); + await failure; + expect(dataFetch).toHaveBeenCalledTimes(3); setVersion(TIMESTAMP + 100); mockDatafileResponse(TIMESTAMP + 1, true); @@ -419,7 +435,7 @@ describe('Vercel mode (black-box)', () => { value: true, metrics: { mode: 'polling' }, }); - expect(dataFetch).toHaveBeenCalledTimes(2); + expect(dataFetch).toHaveBeenCalledTimes(4); expect(streamFetch).not.toHaveBeenCalled(); }); @@ -435,15 +451,21 @@ describe('Vercel mode (black-box)', () => { const snapshot = await instance.getDatafile(); const firstError = new Error('header failure'); setVersion(TIMESTAMP + 2); - dataFetch.mockRejectedValueOnce(firstError); - expect((await instance.evaluate('feature')).value).toBe(true); + rejectDatafileOnce(firstError); + const firstFailure = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(300); + expect((await firstFailure).value).toBe(true); vi.setSystemTime(TIMESTAMP + 1_001); setVersion(undefined); - dataFetch.mockRejectedValueOnce(new Error('poll failure')); - await expect(instance.evaluate('feature')).rejects.toBe(firstError); + rejectDatafileOnce(new Error('poll failure')); + const secondFailure = expect(instance.evaluate('feature')).rejects.toBe( + firstError, + ); + await vi.advanceTimersByTimeAsync(300); + await secondFailure; await expect(instance.getDatafile()).rejects.toBe(firstError); - expect(dataFetch).toHaveBeenCalledTimes(3); + expect(dataFetch).toHaveBeenCalledTimes(7); mockDatafileResponse(TIMESTAMP + 1, true); await vi.advanceTimersByTimeAsync(30_000); @@ -454,7 +476,7 @@ describe('Vercel mode (black-box)', () => { const recovered = await instance.getDatafile(); expect(recovered.definitions).toBe(snapshot.definitions); expect(recovered.fetchedAt).toBe(snapshot.fetchedAt); - expect(dataFetch).toHaveBeenCalledTimes(4); + expect(dataFetch).toHaveBeenCalledTimes(8); expect(streamFetch).not.toHaveBeenCalled(); }); @@ -579,11 +601,13 @@ describe('Vercel mode (black-box)', () => { 0.01, ])('recovers on a later read when the cold-cache fetch fails with staleIfError=%s', async (staleIfError) => { const instance = client({ datafile: undefined, staleIfError }); - dataFetch.mockResolvedValueOnce(new Response(null, { status: 503 })); - await expect(instance.evaluate('feature')).rejects.toThrow( + mockDatafileHttpFailure(); + const failure = expect(instance.evaluate('feature')).rejects.toThrow( 'Failed to fetch data', ); - expect(dataFetch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(300); + await failure; + expect(dataFetch).toHaveBeenCalledTimes(3); await vi.advanceTimersByTimeAsync(11); mockDatafileResponse(TIMESTAMP, true); @@ -591,7 +615,7 @@ describe('Vercel mode (black-box)', () => { value: true, metrics: { mode: 'vercel', cacheStatus: 'MISS' }, }); - expect(dataFetch).toHaveBeenCalledTimes(2); + expect(dataFetch).toHaveBeenCalledTimes(4); }); it.each([ @@ -935,12 +959,12 @@ describe('Vercel mode (black-box)', () => { it('retries a failed blocking refresh instead of poisoning subsequent evaluations', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); setVersion(TIMESTAMP + 20_000); - dataFetch.mockResolvedValueOnce( - new Response(null, { status: 503, statusText: 'Service Unavailable' }), - ); + mockDatafileHttpFailure('Service Unavailable'); const instance = client({ staleIfError: 0 }); - const failed = await instance.evaluate('feature', false); + const failedEvaluation = instance.evaluate('feature', false); + await vi.advanceTimersByTimeAsync(300); + const failed = await failedEvaluation; expect(failed.value).toBe(false); expect(failed.reason).toBe('error'); expect(failed.errorMessage).toContain('Service Unavailable'); @@ -952,7 +976,7 @@ describe('Vercel mode (black-box)', () => { const recovered = await instance.evaluate('feature'); expect(recovered.value).toBe(true); expect(recovered.metrics?.cacheStatus).toBe('MISS'); - expect(dataFetch).toHaveBeenCalledTimes(2); + expect(dataFetch).toHaveBeenCalledTimes(4); }); it('contains background fetch errors and retries without losing cached data', async () => { @@ -968,8 +992,9 @@ describe('Vercel mode (black-box)', () => { expect((await instance.evaluate('feature')).value).toBe(false); const failure = new Error('Network unavailable'); + dataFetch.mockRejectedValueOnce(failure).mockRejectedValueOnce(failure); pending.reject(failure); - await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(300); expect(errorSpy).toHaveBeenCalledExactlyOnceWith( '@vercel/flags-core: Revalidation failed:', failure, @@ -981,7 +1006,7 @@ describe('Vercel mode (black-box)', () => { await vi.advanceTimersByTimeAsync(0); expect((await instance.evaluate('feature')).value).toBe(true); - expect(dataFetch).toHaveBeenCalledTimes(2); + expect(dataFetch).toHaveBeenCalledTimes(4); }); it('aborts an in-flight header refresh on shutdown', async () => { @@ -1342,11 +1367,11 @@ describe('Vercel mode (black-box)', () => { await instance.evaluate('feature'); vi.setSystemTime(TIMESTAMP + 9_000); setVersion(TIMESTAMP + 1); - dataFetch.mockResolvedValueOnce(new Response(null, { status: 503 })); + mockDatafileHttpFailure(); expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( 'STALE', ); - await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(300); expect(errorSpy).toHaveBeenCalledExactlyOnceWith( '@vercel/flags-core: Revalidation failed:', expect.any(Error), @@ -1357,7 +1382,7 @@ describe('Vercel mode (black-box)', () => { expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( 'MISS', ); - expect(dataFetch).toHaveBeenCalledTimes(2); + expect(dataFetch).toHaveBeenCalledTimes(4); }); it.each([ @@ -1419,52 +1444,62 @@ describe('Vercel mode (black-box)', () => { const instance = client(); dataFetch.mockRejectedValue(new Error('service unavailable')); - expect(await instance.evaluate('feature')).toMatchObject({ + const firstFailure = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(300); + expect(await firstFailure).toMatchObject({ value: false, metrics: { cacheStatus: 'STALE' }, }); vi.setSystemTime(TIMESTAMP + 365 * 24 * 60 * 60 * 1_000); - expect(await instance.evaluate('feature')).toMatchObject({ + const secondFailure = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(300); + expect(await secondFailure).toMatchObject({ value: false, metrics: { cacheStatus: 'STALE' }, }); expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); - expect(dataFetch).toHaveBeenCalledTimes(2); + expect(dataFetch).toHaveBeenCalledTimes(6); }); it('shares the first-error deadline with snapshots and recovers after expiry', async () => { const instance = client({ staleIfError: 1 }); setVersion(TIMESTAMP + 1); const firstError = new Error('first failure'); - dataFetch.mockRejectedValueOnce(firstError); - expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( - 'STALE', - ); + rejectDatafileOnce(firstError); + const firstFailure = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(300); + expect((await firstFailure).metrics?.cacheStatus).toBe('STALE'); vi.setSystemTime(TIMESTAMP + 1_000); - dataFetch.mockRejectedValueOnce(new Error('second failure')); - expect((await instance.evaluate('feature')).value).toBe(false); + rejectDatafileOnce(new Error('second failure')); + const secondFailure = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(300); + expect((await secondFailure).value).toBe(false); expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); - vi.setSystemTime(TIMESTAMP + 1_001); + vi.setSystemTime(TIMESTAMP + 1_301); // Older/malformed evidence must not reset the first-error deadline or fetch. for (const version of ['invalid', TIMESTAMP - 1, TIMESTAMP]) { setVersion(version); await expect(instance.evaluate('feature')).rejects.toBe(firstError); await expect(instance.getDatafile()).rejects.toBe(firstError); } - expect(dataFetch).toHaveBeenCalledTimes(2); + expect(dataFetch).toHaveBeenCalledTimes(6); setVersion(TIMESTAMP + 1); - dataFetch.mockRejectedValueOnce(new Error('third failure')); - await expect(instance.evaluate('feature')).rejects.toBe(firstError); + rejectDatafileOnce(new Error('third failure')); + const thirdFailure = expect(instance.evaluate('feature')).rejects.toBe( + firstError, + ); + await vi.advanceTimersByTimeAsync(300); + await thirdFailure; mockDatafileResponse(TIMESTAMP + 1, true); expect(await instance.evaluate('feature')).toMatchObject({ value: true, metrics: { cacheStatus: 'MISS' }, }); expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); - expect(dataFetch).toHaveBeenCalledTimes(4); + expect(dataFetch).toHaveBeenCalledTimes(10); }); it.each([ @@ -1477,9 +1512,9 @@ describe('Vercel mode (black-box)', () => { }); setVersion(TIMESTAMP + 1); const failure = new Error('background failure'); - dataFetch.mockRejectedValueOnce(failure); + rejectDatafileOnce(failure); expect((await instance.evaluate('feature')).value).toBe(false); - await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(300); expect(errorSpy).toHaveBeenCalledExactlyOnceWith( '@vercel/flags-core: Revalidation failed:', failure, @@ -1500,7 +1535,7 @@ describe('Vercel mode (black-box)', () => { }); await vi.advanceTimersByTimeAsync(0); expect(settled).not.toHaveBeenCalled(); - expect(dataFetch).toHaveBeenCalledTimes(2); + expect(dataFetch).toHaveBeenCalledTimes(4); pending.resolve(Response.json(datafile(TIMESTAMP + delta, true))); await outcome; From 61e796d23ee6ac461219e53747eb095237cfddf0 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 30 Sep 2026 13:29:13 +0200 Subject: [PATCH 21/41] fix(flags-core): track header authorization state --- .../src/controller/header-source.ts | 17 ++++++-- .../vercel-flags-core/src/controller/index.ts | 7 +++- .../src/vercel-mode.black-box.test.ts | 41 +++++++++++++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index dabbc481c..6ea53945a 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -8,6 +8,7 @@ import { TypedEmitter } from './typed-emitter'; export type HeaderSourceEvents = { data: (data: DatafileInput) => void; confirmed: (data: CacheMetadata) => void; + error: (error: Error) => void; }; /** Request version evidence and fetching; the cache decides how to serve reads. */ @@ -72,10 +73,18 @@ export class HeaderSource extends TypedEmitter { } fetch = async (signal: AbortSignal): Promise => { - const data = await fetchDatafile({ ...this.options, signal }); - // Transports can finish after cancellation; never publish that response. - signal.throwIfAborted(); - this.emit('data', data); + try { + const data = await fetchDatafile({ ...this.options, signal }); + // Transports can finish after cancellation; never publish that response. + signal.throwIfAborted(); + this.emit('data', data); + } catch (error) { + signal.throwIfAborted(); + const err = + error instanceof Error ? error : new Error('Unknown header error'); + this.emit('error', err); + throw err; + } }; isEnabled(): boolean { diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 47d22085a..81719ae9c 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -192,6 +192,7 @@ export class Controller implements ControllerInterface { this.cache.updateFromSource(data, 'poll'); }; private onHeaderData = (data: DatafileInput) => { + this.unauthorized = false; this.cache.updateFromSource(data, 'fetched'); }; private onHeaderConfirmed = (data: CacheMetadata) => { @@ -213,6 +214,7 @@ export class Controller implements ControllerInterface { this.pollingSource.on('error', this.onSourceError); this.headerSource.on('data', this.onHeaderData); this.headerSource.on('confirmed', this.onHeaderConfirmed); + this.headerSource.on('error', this.onSourceError); } private unwireSourceEvents(): void { @@ -226,6 +228,7 @@ export class Controller implements ControllerInterface { this.pollingSource.off('error', this.onSourceError); this.headerSource.off('data', this.onHeaderData); this.headerSource.off('confirmed', this.onHeaderConfirmed); + this.headerSource.off('error', this.onSourceError); } // --------------------------------------------------------------------------- @@ -645,7 +648,9 @@ export class Controller implements ControllerInterface { private noteUnauthorized(error: unknown): void { if ( error instanceof UnauthorizedError || - (error instanceof Error && error.message.includes('401')) + (error instanceof Error && + (error.message.includes('401') || + ('status' in error && error.status === 401))) ) { this.unauthorized = true; } diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index ab55ae902..92d0c731a 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -1634,4 +1634,45 @@ describe('Vercel mode (black-box)', () => { ]), ); }); + + it('suppresses usage after a header 401 and resumes after recovery', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const instance = client({ staleWhileRevalidate: 0 }); + await instance.initialize(); + + setVersion(TIMESTAMP + 1); + dataFetch.mockResolvedValueOnce( + new Response(null, { status: 401, statusText: 'Unauthorized' }), + ); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { mode: 'vercel', cacheStatus: 'STALE' }, + }); + + setVersion(TIMESTAMP + 1); + mockDatafileResponse(TIMESTAMP + 1, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'vercel', cacheStatus: 'MISS' }, + }); + + await instance.shutdown(); + clients.delete(instance); + + const events = transport.mock.calls + .filter(([url]) => String(url).endsWith('/v1/ingest')) + .flatMap(([, init]) => JSON.parse(String(init?.body))) as Array<{ + type: string; + payload: { evaluationCount?: number }; + }>; + expect( + events.filter(({ type }) => type === 'FLAGS_CONFIG_READ'), + ).toHaveLength(1); + expect( + events.find(({ type }) => type === 'FLAG_EVALUATION')?.payload + .evaluationCount, + ).toBe(1); + expect(dataFetch).toHaveBeenCalledTimes(2); + expect(errorSpy).not.toHaveBeenCalled(); + }); }); From defa6d57fd32e35b99908bb54435a490a46739f7 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 30 Sep 2026 15:28:55 +0200 Subject: [PATCH 22/41] refactor(flags-core): make cache freshness evidence explicit --- packages/vercel-flags-core/CLAUDE.md | 11 +- .../controller/datafile-cache-policy.test.ts | 129 +++++++++--------- .../src/controller/datafile-cache.ts | 18 ++- .../src/controller/header-source.ts | 33 +++-- .../vercel-flags-core/src/controller/index.ts | 21 +-- .../src/controller/polling-source.ts | 7 +- .../src/controller/stream-source.ts | 7 +- 7 files changed, 118 insertions(+), 108 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index f8024310b..cb43ecd19 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -131,7 +131,7 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu - Do not start stream/poll; the first read fetches if the cache is empty. - HeaderSource parses the request's project version and owns `highestObserved`. The cache owns freshness age. - A matching header confirms freshness only when no newer version has been observed. -- The controller passes `getStatus` and `fetch` callbacks to `cache.resolve()`. +- The controller passes `assess` and `fetch` callbacks to `cache.resolve()`. The cache selects cached/background/blocking behavior and shares refresh work. - A newer header permits background refresh within `staleWhileRevalidate` seconds of the latest accepted fetch or valid confirmation; unknown/expired cache age blocks for refresh. @@ -343,11 +343,12 @@ version/revision confirmations clear it; repeated errors/disconnects do not rene the first-error deadline. Stream opening and initialization timeout alone are not recovery/failure evidence respectively. -`cache.resolve(policy)` receives a mode-specific `getStatus` callback returning -`fresh`, `stale`, `expired`, or `unknown`, and an optional `fetch` callback. +`cache.resolve(policy)` receives a mode-specific `assess` callback returning +`{ status, confirmed? }`, where status is `fresh`, `stale`, `expired`, or `unknown`, +and an optional `fetch` callback. It owns background/blocking decisions, `waitUntil`, shared revalidation, and cancellation on clear. HeaderSource supplies small version/age -checks and a fetch callback; it does not read the cache. Header confirmations are -forwarded through controller event wiring. Stream/poll modes omit on-read revalidation +checks and a fetch callback; it does not read the cache. Header assessments return confirmation evidence explicitly; the cache applies it +before enforcing stale-if-error, without a controller event round trip. Stream/poll modes omit on-read revalidation and retain their existing schedules. New public time windows use seconds; internal normalized durations, cache age, and `fetchedAt` use milliseconds. Polling is fresh through its interval; streaming through 30 seconds. Accepted updates, valid confirmations, diff --git a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts index d00ccc129..dba8159f6 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts @@ -67,7 +67,7 @@ describe('cache read callbacks', () => { const cache = new DatafileCache(0); const original = tagData(data(), 'provided'); cache.seed(original); - const policy = { getStatus: vi.fn(() => status) }; + const policy = { assess: vi.fn(() => ({ status })) }; expect(await cache.resolve(policy)).toEqual([ original, status === 'fresh' ? 'HIT' : 'STALE', @@ -75,14 +75,14 @@ describe('cache read callbacks', () => { const error = new Error('outage'); cache.fail(error); await expect(cache.resolve(policy)).rejects.toBe(error); - expect(policy.getStatus).toHaveBeenCalledTimes(2); + expect(policy.assess).toHaveBeenCalledTimes(2); }); it('returns undefined without assessing an empty cache when fetch is omitted', async () => { const cache = new DatafileCache(); - const getStatus = vi.fn(() => 'fresh' as const); - expect(await cache.resolve({ getStatus })).toBeUndefined(); - expect(getStatus).not.toHaveBeenCalled(); + const assess = vi.fn(() => ({ status: 'fresh' as const })); + expect(await cache.resolve({ assess })).toBeUndefined(); + expect(assess).not.toHaveBeenCalled(); }); it.each([ @@ -93,7 +93,7 @@ describe('cache read callbacks', () => { const original = tagData(data(), 'provided'); cache.seed(original); const policy = { - getStatus: vi.fn(() => status), + assess: vi.fn(() => ({ status })), fetch: vi.fn(async () => {}), }; @@ -101,7 +101,7 @@ describe('cache read callbacks', () => { original, status === 'fresh' ? 'HIT' : 'STALE', ]); - expect(policy.getStatus).toHaveBeenCalledExactlyOnceWith({ + expect(policy.assess).toHaveBeenCalledExactlyOnceWith({ projectId: 'prj_policy', environment: 'production', configUpdatedAt: 1, @@ -111,7 +111,7 @@ describe('cache read callbacks', () => { const failure = new Error('outage'); cache.fail(failure); await expect(cache.resolve(policy)).rejects.toBe(failure); - expect(policy.getStatus).toHaveBeenCalledTimes(2); + expect(policy.assess).toHaveBeenCalledTimes(2); expect(policy.fetch).not.toHaveBeenCalled(); }); @@ -126,7 +126,7 @@ describe('cache read callbacks', () => { }); const settled = vi.fn(); const reading = cache - .resolve({ getStatus: () => 'expired' as const, fetch }) + .resolve({ assess: () => ({ status: 'expired' as const }), fetch }) .then(settled); await vi.advanceTimersByTimeAsync(0); expect(settled).not.toHaveBeenCalled(); @@ -147,7 +147,7 @@ describe('cache read callbacks', () => { .fn>() .mockRejectedValueOnce(firstError) .mockRejectedValue(new Error('later outage')); - const policy = { getStatus: () => 'expired' as const, fetch }; + const policy = { assess: () => ({ status: 'expired' as const }), fetch }; expect(await cache.resolve(policy)).toEqual([original, 'STALE']); vi.setSystemTime(1_100); expect(await cache.resolve(policy)).toEqual([original, 'STALE']); @@ -161,7 +161,7 @@ describe('cache read callbacks', () => { cache.seed(tagData(data(), 'provided')); const fetch = vi.fn().mockRejectedValue('transport failed'); await expect( - cache.resolve({ getStatus: () => 'expired' as const, fetch }), + cache.resolve({ assess: () => ({ status: 'expired' as const }), fetch }), ).rejects.toThrow('Unknown fetch error'); expect(() => cache.read()).toThrow('Unknown fetch error'); expect(fetch).toHaveBeenCalledTimes(1); @@ -177,7 +177,10 @@ describe('cache read callbacks', () => { const failure = new Error('fetch failed'); const fetch = vi.fn().mockRejectedValue(failure); expect( - await cache.resolve({ getStatus: () => 'stale' as const, fetch }), + await cache.resolve({ + assess: () => ({ status: 'stale' as const }), + fetch, + }), ).toEqual([original, 'STALE']); await waitUntil.mock.calls[0]?.[0]; expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); @@ -200,12 +203,12 @@ describe('cache read callbacks', () => { await pending.promise; cache.updateFromSource(data(2), 'fetched'); }); - const policy = { getStatus: () => 'stale' as const, fetch }; + const policy = { assess: () => ({ status: 'stale' as const }), fetch }; expect(await cache.resolve(policy)).toEqual([original, 'STALE']); expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); const settled = vi.fn(); const blocking = cache - .resolve({ ...policy, getStatus: () => 'expired' as const }) + .resolve({ ...policy, assess: () => ({ status: 'expired' as const }) }) .then((result) => { settled(); return result; @@ -228,7 +231,7 @@ describe('cache read callbacks', () => { const fetch = vi .fn>() .mockRejectedValueOnce(failure); - const policy = { getStatus: () => 'stale' as const, fetch }; + const policy = { assess: () => ({ status: 'stale' as const }), fetch }; expect((await cache.resolve(policy))?.[1]).toBe('STALE'); await waitUntil.mock.calls[0]?.[0]; @@ -254,7 +257,7 @@ describe('cache read callbacks', () => { const fetch = vi.fn>(() => { throw failure; }); - const policy = { getStatus: () => 'expired' as const, fetch }; + const policy = { assess: () => ({ status: 'expired' as const }), fetch }; await expect(cache.resolve(policy)).rejects.toBe(failure); fetch.mockImplementationOnce(async () => cache.updateFromSource(data(), 'fetched'), @@ -267,7 +270,7 @@ describe('cache read callbacks', () => { const cache = new DatafileCache(); const fetch = vi.fn(async () => {}); const reading = cache.resolve({ - getStatus: () => 'expired' as const, + assess: () => ({ status: 'expired' as const }), fetch, }); const outcome = expect(reading).rejects.toThrow(); @@ -289,7 +292,7 @@ describe('cache read callbacks', () => { signal.throwIfAborted(); cache.updateFromSource(data(2), 'fetched'); }); - const policy = { getStatus: () => 'expired' as const, fetch }; + const policy = { assess: () => ({ status: 'expired' as const }), fetch }; const oldRead = cache.resolve(policy); const cancelled = expect(oldRead).rejects.toThrow('cancelled transport'); await vi.advanceTimersByTimeAsync(0); @@ -326,7 +329,7 @@ describe('header freshness policy', () => { ); } - function statusCheck(headerSource: HeaderSource, header: string | undefined) { + function assessment(headerSource: HeaderSource, header: string | undefined) { vi.mocked(getRequestContext).mockReturnValue({ ctx: undefined, headers: @@ -334,7 +337,7 @@ describe('header freshness policy', () => { ? undefined : { 'x-vercel-flags-config-versions': header }, }); - return headerSource.getStatusCheck(); + return headerSource.getAssessment(); } it.each([ @@ -347,12 +350,9 @@ describe('header freshness policy', () => { 'flags_prj_policy=Infinity', ])('treats missing or malformed header %s as unknown', (header) => { const headerSource = source(); - const confirmed = vi.fn(); - headerSource.on('confirmed', confirmed); - expect(statusCheck(headerSource, header)({ ...data(), ageMs: 0 })).toBe( - 'unknown', - ); - expect(confirmed).not.toHaveBeenCalled(); + expect(assessment(headerSource, header)({ ...data(), ageMs: 0 })).toEqual({ + status: 'unknown', + }); }); it.each([ @@ -364,10 +364,8 @@ describe('header freshness policy', () => { 0, ])('treats missing or invalid cached timestamp %s as unknown', (configUpdatedAt) => { const headerSource = source(); - const confirmed = vi.fn(); - headerSource.on('confirmed', confirmed); expect( - statusCheck( + assessment( headerSource, 'flags_prj_policy=2', )({ @@ -375,8 +373,7 @@ describe('header freshness policy', () => { configUpdatedAt, ageMs: 0, }), - ).toBe('unknown'); - expect(confirmed).not.toHaveBeenCalled(); + ).toEqual({ status: 'unknown' }); }); it.each([ @@ -391,16 +388,16 @@ describe('header freshness policy', () => { [3, 2, 501, 0.5, 'expired'], ])('assesses header %s against timestamp %s with age %s and SWR %s as %s', (headerTs, currentTs, ageMs, swr, status) => { const headerSource = source(swr); - const confirmed = vi.fn(); - headerSource.on('confirmed', confirmed); const metadata = { ...data(currentTs), ageMs }; expect( - statusCheck( + assessment( headerSource, `flags_other=99; flags_prj_policy=${headerTs}`, )(metadata), - ).toBe(status); - expect(confirmed).toHaveBeenCalledTimes(headerTs === currentTs ? 1 : 0); + ).toEqual({ + status, + ...(status === 'fresh' ? { confirmed: headerTs === currentTs } : {}), + }); }); it('resets cache age on an equal highest-observed header, then blocks older confirmations until stop', async () => { @@ -410,62 +407,65 @@ describe('header freshness policy', () => { ); cache.seed(original); const headerSource = source(); - const confirmed = vi.fn((metadata) => cache.tryConfirm(metadata)); - headerSource.on('confirmed', confirmed); + const matching = assessment(headerSource, 'flags_prj_policy=1'); + const initialFailure = new Error('initial outage'); + cache.fail(initialFailure); + expect(matching(cache.metadata!)).toEqual({ + status: 'fresh', + confirmed: true, + }); + // Assessing the header returns evidence; only the cache applies recovery. + expect(cache.ageMs).toBe(500); + expect(() => cache.read()).toThrow(initialFailure); expect( await cache.resolve({ - getStatus: statusCheck(headerSource, 'flags_prj_policy=1'), + assess: matching, }), ).toEqual([original, 'HIT']); expect(cache.ageMs).toBe(0); expect(original.fetchedAt).toBe(500); expect(original._origin).toBe('bundled'); - expect(confirmed).toHaveBeenCalledTimes(1); vi.setSystemTime(1_100); expect( await cache.resolve({ - getStatus: statusCheck(headerSource, 'flags_prj_policy=2'), + assess: assessment(headerSource, 'flags_prj_policy=2'), }), ).toEqual([original, 'STALE']); const error = new Error('outage'); cache.fail(error); await expect( cache.resolve({ - getStatus: statusCheck(headerSource, 'flags_prj_policy=1'), + assess: assessment(headerSource, 'flags_prj_policy=1'), }), ).rejects.toBe(error); expect(cache.ageMs).toBe(100); - expect(confirmed).toHaveBeenCalledTimes(1); headerSource.stop(); expect( await cache.resolve({ - getStatus: statusCheck(headerSource, 'flags_prj_policy=1'), + assess: assessment(headerSource, 'flags_prj_policy=1'), }), ).toEqual([original, 'HIT']); expect(cache.ageMs).toBe(0); - expect(confirmed).toHaveBeenCalledTimes(2); expect(original.fetchedAt).toBe(500); }); it('assesses the captured raw header after a shared cold fetch discovers the project', async () => { const cache = new DatafileCache(0); const headerSource = source(); - const confirmed = vi.fn((metadata) => cache.tryConfirm(metadata)); - headerSource.on('confirmed', confirmed); const headers = { 'x-vercel-flags-config-versions': 'flags_prj_policy=2' }; vi.mocked(getRequestContext).mockReturnValue({ ctx: undefined, headers }); - const originalCheck = vi.fn(headerSource.getStatusCheck()); + const originalCheck = vi.fn(headerSource.getAssessment()); const pending = deferred(); const fetch = vi.fn(async () => { await pending.promise; cache.updateFromSource(data(), 'fetched'); }); - const firstRead = cache.resolve({ getStatus: originalCheck, fetch }); + const firstRead = cache.resolve({ assess: originalCheck, fetch }); headers['x-vercel-flags-config-versions'] = 'flags_prj_policy=1'; - const laterCheck = vi.fn(headerSource.getStatusCheck()); - const secondRead = cache.resolve({ getStatus: laterCheck, fetch }); + const laterCheck = vi.fn(headerSource.getAssessment()); + const secondRead = cache.resolve({ assess: laterCheck, fetch }); await vi.advanceTimersByTimeAsync(0); expect(originalCheck).not.toHaveBeenCalled(); expect(laterCheck).not.toHaveBeenCalled(); @@ -475,19 +475,20 @@ describe('header freshness policy', () => { [cache.read(), 'MISS'], ]); expect(fetch).toHaveBeenCalledTimes(1); - expect(originalCheck).toHaveReturnedWith('stale'); - expect(laterCheck).toHaveReturnedWith('fresh'); + expect(originalCheck).toHaveReturnedWith({ status: 'stale' }); + expect(laterCheck).toHaveReturnedWith({ + status: 'fresh', + confirmed: false, + }); expect(originalCheck).toHaveBeenCalledTimes(1); expect(laterCheck).toHaveBeenCalledTimes(1); - expect(confirmed).not.toHaveBeenCalled(); vi.setSystemTime(1_100); cache.fail(new Error('outage')); - await expect(cache.resolve({ getStatus: laterCheck })).rejects.toThrow( + await expect(cache.resolve({ assess: laterCheck })).rejects.toThrow( 'outage', ); expect(cache.ageMs).toBe(100); - expect(confirmed).not.toHaveBeenCalled(); }); it('accepts the fallback header and gives the Vercel header precedence', () => { @@ -496,9 +497,9 @@ describe('header freshness policy', () => { ctx: undefined, headers: { 'flags-config-versions': 'flags_prj_policy=1' }, }); - expect(headerSource.getStatusCheck()({ ...data(), ageMs: Infinity })).toBe( - 'fresh', - ); + expect( + headerSource.getAssessment()({ ...data(), ageMs: Infinity }), + ).toEqual({ status: 'fresh', confirmed: true }); vi.mocked(getRequestContext).mockReturnValue({ ctx: undefined, headers: { @@ -506,9 +507,9 @@ describe('header freshness policy', () => { 'flags-config-versions': 'flags_prj_policy=1', }, }); - expect(headerSource.getStatusCheck()({ ...data(), ageMs: Infinity })).toBe( - 'expired', - ); + expect( + headerSource.getAssessment()({ ...data(), ageMs: Infinity }), + ).toEqual({ status: 'expired' }); }); it('emits raw fetched data and confirms equal responses without changing fetchedAt', async () => { @@ -544,7 +545,7 @@ describe('header freshness policy', () => { expect(incoming).not.toHaveProperty('fetchedAt'); expect( await cache.resolve({ - getStatus: statusCheck(headerSource, 'flags_prj_policy=2'), + assess: assessment(headerSource, 'flags_prj_policy=2'), }), ).toEqual([original, 'STALE']); }); @@ -560,7 +561,7 @@ describe('header freshness policy', () => { const onData = vi.fn((raw) => cache.updateFromSource(raw, 'fetched')); headerSource.on('data', onData); const reading = cache.resolve({ - getStatus: statusCheck(headerSource, 'flags_prj_policy=2'), + assess: assessment(headerSource, 'flags_prj_policy=2'), fetch: headerSource.fetch, }); const outcome = expect(reading).rejects.toThrow(); diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 04a2e7b46..6ffa55eec 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -10,12 +10,18 @@ export type CacheMetadata = Confirmation & { ageMs: number }; export type Freshness = 'fresh' | 'stale' | 'expired' | 'unknown'; +export type CacheAssessment = { + status: Freshness; + /** Positive evidence that renews age and clears the current failure. */ + confirmed?: boolean; +}; + type Fetch = (signal: AbortSignal) => Promise; type CacheResult = [TaggedData, Metrics['cacheStatus']]; export type CacheReadPolicy = { /** Unknown adds no freshness evidence and keeps cached-read behavior. */ - getStatus: (data: CacheMetadata) => Freshness; + assess: (data: CacheMetadata) => CacheAssessment; /** Omit for modes whose stream/poll loop already maintains the cache. */ fetch?: Fetch; }; @@ -177,8 +183,9 @@ export class DatafileCache { async resolve(policy: CacheReadPolicy): Promise { const metadata = this.metadata; if (metadata) { - // The assessment may confirm recovery, so run it before read() checks failure. - const status = policy.getStatus(metadata); + const { status, confirmed } = policy.assess(metadata); + // Apply recovery evidence before read() enforces the failure deadline. + if (confirmed) this.confirm(); if (status === 'fresh' || status === 'unknown' || !policy.fetch) { // Stream/poll omit fetch because they maintain the cache independently. // read() still enforces stale-if-error, even for a fresh assessment. @@ -208,7 +215,10 @@ export class DatafileCache { } // A cold fetch discovers the project; assess the original request's header. - if (!metadata && this.metadata) policy.getStatus(this.metadata); + if (!metadata && this.metadata) { + const { confirmed } = policy.assess(this.metadata); + if (confirmed) this.confirm(); + } // Serve the accepted cache entry; the response may have contained older data. const data = this.read(); if (!data) diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 6ea53945a..695a19b18 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -1,13 +1,12 @@ import type { DatafileInput } from '../types'; import { getRequestContext } from '../utils/request-context'; -import type { CacheMetadata, CacheReadPolicy } from './datafile-cache'; +import type { CacheReadPolicy } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import type { NormalizedOptions } from './normalized-options'; import { TypedEmitter } from './typed-emitter'; export type HeaderSourceEvents = { data: (data: DatafileInput) => void; - confirmed: (data: CacheMetadata) => void; error: (error: Error) => void; }; @@ -32,30 +31,36 @@ export class HeaderSource extends TypedEmitter { } /** Capture the header now so a shared fetch cannot switch the request being assessed. */ - getStatusCheck(): CacheReadPolicy['getStatus'] { + getAssessment(): CacheReadPolicy['assess'] { const header = this.getVersionHeader(); return (data) => { const headerTs = this.getUpdatedAtHeader(data.projectId, header); - if (headerTs === undefined) return 'unknown'; + if (headerTs === undefined) return { status: 'unknown' }; const currentTs = Number(data.configUpdatedAt); this.highestObserved = Math.max(this.highestObserved, headerTs); - if (!Number.isFinite(currentTs) || currentTs <= 0) return 'unknown'; - - // An older matching request cannot undo a newer request's invalidation. - if (headerTs === currentTs && headerTs === this.highestObserved) { - this.emit('confirmed', data); + if (!Number.isFinite(currentTs) || currentTs <= 0) { + return { status: 'unknown' }; } - // This request is satisfied; only confirmation above can renew age or clear failure. - if (headerTs <= currentTs) return 'fresh'; + if (headerTs <= currentTs) { + // Older requests are satisfied without undoing a newer request's invalidation. + return { + status: 'fresh', + confirmed: + headerTs === currentTs && headerTs === this.highestObserved, + }; + } const { staleWhileRevalidateMs } = this.options; - return staleWhileRevalidateMs > 0 && data.ageMs <= staleWhileRevalidateMs - ? 'stale' - : 'expired'; + return { + status: + staleWhileRevalidateMs > 0 && data.ageMs <= staleWhileRevalidateMs + ? 'stale' + : 'expired', + }; }; } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 81719ae9c..a0af4d9b0 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -11,11 +11,7 @@ import type { TrackEvaluationOptions } from '../utils/usage/flags-evaluation'; import { UsageTracker } from '../utils/usage-tracker'; import { unauthorizedMessage } from './auth'; import { BundledSource } from './bundled-source'; -import { - type CacheMetadata, - type CacheReadPolicy, - DatafileCache, -} from './datafile-cache'; +import { type CacheReadPolicy, DatafileCache } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { HeaderSource } from './header-source'; import { @@ -195,9 +191,6 @@ export class Controller implements ControllerInterface { this.unauthorized = false; this.cache.updateFromSource(data, 'fetched'); }; - private onHeaderConfirmed = (data: CacheMetadata) => { - this.cache.tryConfirm(data); - }; // --------------------------------------------------------------------------- // Source event wiring @@ -213,7 +206,6 @@ export class Controller implements ControllerInterface { this.pollingSource.on('data', this.onPollData); this.pollingSource.on('error', this.onSourceError); this.headerSource.on('data', this.onHeaderData); - this.headerSource.on('confirmed', this.onHeaderConfirmed); this.headerSource.on('error', this.onSourceError); } @@ -227,7 +219,6 @@ export class Controller implements ControllerInterface { this.pollingSource.off('data', this.onPollData); this.pollingSource.off('error', this.onSourceError); this.headerSource.off('data', this.onHeaderData); - this.headerSource.off('confirmed', this.onHeaderConfirmed); this.headerSource.off('error', this.onSourceError); } @@ -405,7 +396,7 @@ export class Controller implements ControllerInterface { // Snapshots must not turn request headers into freshness evidence. const status = metadata && this.state !== 'vercel' - ? this.cacheReadPolicy.getStatus(metadata) + ? this.cacheReadPolicy.assess(metadata).status : 'unknown'; cacheStatus = status === 'fresh' ? 'HIT' : 'STALE'; @@ -517,21 +508,21 @@ export class Controller implements ControllerInterface { private get cacheReadPolicy(): CacheReadPolicy { if (this.state === 'vercel') { return { - getStatus: this.headerSource.getStatusCheck(), + assess: this.headerSource.getAssessment(), fetch: this.headerSource.fetch, }; } if (this.state === 'streaming') { - return { getStatus: this.streamSource.getStatus }; + return { assess: this.streamSource.assess }; } // Seeded initialization can leave the active poller in 'initializing:polling'. if (this.state === 'polling' || this.state === 'initializing:polling') { - return { getStatus: this.pollingSource.getStatus }; + return { assess: this.pollingSource.assess }; } - return { getStatus: () => 'unknown' }; + return { assess: () => ({ status: 'unknown' }) }; } // --------------------------------------------------------------------------- diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index 60ee20840..1c4236f77 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -1,6 +1,6 @@ import type { DatafileInput } from '../types'; import type { Auth } from './auth'; -import type { CacheMetadata, Freshness } from './datafile-cache'; +import type { CacheAssessment, CacheMetadata } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { TypedEmitter } from './typed-emitter'; @@ -32,8 +32,9 @@ export class PollingSource extends TypedEmitter { this.config = config; } - getStatus = ({ ageMs }: Pick): Freshness => - ageMs <= this.config.polling.intervalMs ? 'fresh' : 'stale'; + assess = ({ ageMs }: Pick): CacheAssessment => ({ + status: ageMs <= this.config.polling.intervalMs ? 'fresh' : 'stale', + }); /** * Perform a single poll request. diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index 624c66fd1..aae46a5d3 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -1,5 +1,5 @@ import type { DatafileInput } from '../types'; -import type { CacheMetadata, Freshness } from './datafile-cache'; +import type { CacheAssessment, CacheMetadata } from './datafile-cache'; import type { NormalizedOptions } from './normalized-options'; import { connectStream, type PrimedMessage } from './stream-connection'; import { TypedEmitter } from './typed-emitter'; @@ -29,8 +29,9 @@ export class StreamSource extends TypedEmitter { this.revision = revision; } - getStatus = ({ ageMs }: Pick): Freshness => - ageMs <= 30_000 ? 'fresh' : 'stale'; + assess = ({ ageMs }: Pick): CacheAssessment => ({ + status: ageMs <= 30_000 ? 'fresh' : 'stale', + }); /** * Start the stream connection. From cbe10aa709739705e02395557f450f24601aeffd Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 30 Sep 2026 15:29:00 +0200 Subject: [PATCH 23/41] refactor(flags-core): let header reads finish across source fallback --- packages/vercel-flags-core/CLAUDE.md | 14 ++- packages/vercel-flags-core/README.md | 6 +- .../controller/datafile-cache-policy.test.ts | 35 ++++-- .../src/controller/datafile-cache.ts | 22 +--- .../vercel-flags-core/src/controller/index.ts | 31 ++---- .../src/vercel-mode.black-box.test.ts | 105 +++++++++++++----- 6 files changed, 132 insertions(+), 81 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index cb43ecd19..de828e41a 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -139,8 +139,9 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu `staleIfError` allowance; expiry forces blocking recovery on the next newer-header read. - Evaluations without a version header (including an empty header) permanently start streaming if enabled, otherwise polling, using the existing startup timeouts. - Concurrent reads share source startup. Pending header fetches are cancelled without - clearing stored data or the failure deadline; their readers resume through the new source. + Concurrent new reads share source startup. Pending header reads finish independently; + successful responses still pass the cache version guard, while errors from the retired + header source do not change cache failure or authorization state. `resolveData()` checks header availability and uses `resolveDataWithFallbacks()` to start the configured source. Handover retains cached data before considering seeds. - Present malformed/unrelated headers use cached data without fetching, subject to stale-if-error. @@ -346,9 +347,12 @@ are not recovery/failure evidence respectively. `cache.resolve(policy)` receives a mode-specific `assess` callback returning `{ status, confirmed? }`, where status is `fresh`, `stale`, `expired`, or `unknown`, and an optional `fetch` callback. -It owns background/blocking decisions, `waitUntil`, shared revalidation, and cancellation on clear. HeaderSource supplies small version/age -checks and a fetch callback; it does not read the cache. Header assessments return confirmation evidence explicitly; the cache applies it -before enforcing stale-if-error, without a controller event round trip. Stream/poll modes omit on-read revalidation +It owns background/blocking decisions, `waitUntil`, shared revalidation, and cancellation +on clear. HeaderSource supplies small version/age checks and a fetch callback; it does +not read the cache. Header assessments return confirmation evidence explicitly; the +cache applies it before enforcing stale-if-error, without a controller event round trip. +Source data/error events update the cache before the fetch promise settles; rejecting +the promise does not record a second failure. Stream/poll modes omit on-read revalidation and retain their existing schedules. New public time windows use seconds; internal normalized durations, cache age, and `fetchedAt` use milliseconds. Polling is fresh through its interval; streaming through 30 seconds. Accepted updates, valid confirmations, diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index 119c056ce..62c6c5933 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -39,8 +39,10 @@ When `VERCEL=1`, the client defaults to `vercel: true`. Initialization loads pro or bundled definitions without starting a stream or polling. Request version headers indicate when cached definitions need refreshing. If an evaluation has no version header (or an empty one), the client permanently switches to streaming when enabled, -otherwise polling. Concurrent evaluations share that startup and later headers do -not switch the client back. A present but malformed or unrelated header keeps the +otherwise polling. Concurrent new evaluations share that startup and later headers do +not switch the client back. Header reads already in progress finish independently; +successful responses can still update the cache, while their errors do not mark the +active stream or poller as failed. A present but malformed or unrelated header keeps the existing cached-read behavior, fetching only when the cache is empty. ```ts diff --git a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts index dba8159f6..86e0e66db 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts @@ -138,32 +138,39 @@ describe('cache read callbacks', () => { expect(cache.read()?.configUpdatedAt).toBe(2); }); - it('keeps the first fetch failure and its inclusive deadline across later attempts', async () => { + it('keeps the first source failure and its inclusive deadline across later attempts', async () => { const cache = new DatafileCache(100); const original = tagData(data(), 'provided'); cache.seed(original); const firstError = new Error('first outage'); + const laterError = new Error('later outage'); const fetch = vi .fn>() .mockRejectedValueOnce(firstError) - .mockRejectedValue(new Error('later outage')); + .mockRejectedValue(laterError); const policy = { assess: () => ({ status: 'expired' as const }), fetch }; + cache.fail(firstError); expect(await cache.resolve(policy)).toEqual([original, 'STALE']); vi.setSystemTime(1_100); + cache.fail(laterError); expect(await cache.resolve(policy)).toEqual([original, 'STALE']); vi.setSystemTime(1_101); await expect(cache.resolve(policy)).rejects.toBe(firstError); expect(fetch).toHaveBeenCalledTimes(3); }); - it('normalizes non-Error failures retained by the cache', async () => { + it('does not record a rejected fetch as failure without source evidence', async () => { const cache = new DatafileCache(0); - cache.seed(tagData(data(), 'provided')); - const fetch = vi.fn().mockRejectedValue('transport failed'); - await expect( - cache.resolve({ assess: () => ({ status: 'expired' as const }), fetch }), - ).rejects.toThrow('Unknown fetch error'); - expect(() => cache.read()).toThrow('Unknown fetch error'); + const original = tagData(data(), 'provided'); + cache.seed(original); + const fetch = vi.fn().mockRejectedValue(new Error('retired source')); + expect( + await cache.resolve({ + assess: () => ({ status: 'expired' as const }), + fetch, + }), + ).toEqual([original, 'STALE']); + expect(cache.read()).toBe(original); expect(fetch).toHaveBeenCalledTimes(1); }); @@ -175,7 +182,10 @@ describe('cache read callbacks', () => { const original = tagData(data(), 'provided'); cache.seed(original); const failure = new Error('fetch failed'); - const fetch = vi.fn().mockRejectedValue(failure); + const fetch = vi.fn(async () => { + cache.fail(failure); + throw failure; + }); expect( await cache.resolve({ assess: () => ({ status: 'stale' as const }), @@ -230,7 +240,10 @@ describe('cache read callbacks', () => { const failure = new Error('refresh failed'); const fetch = vi .fn>() - .mockRejectedValueOnce(failure); + .mockImplementationOnce(async () => { + cache.fail(failure); + throw failure; + }); const policy = { assess: () => ({ status: 'stale' as const }), fetch }; expect((await cache.resolve(policy))?.[1]).toBe('STALE'); diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 6ffa55eec..f022c643d 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -22,7 +22,10 @@ type CacheResult = [TaggedData, Metrics['cacheStatus']]; export type CacheReadPolicy = { /** Unknown adds no freshness evidence and keeps cached-read behavior. */ assess: (data: CacheMetadata) => CacheAssessment; - /** Omit for modes whose stream/poll loop already maintains the cache. */ + /** + * Source events must report data/failure before this settles. + * Omit for modes whose stream/poll loop already maintains the cache. + */ fetch?: Fetch; }; @@ -237,14 +240,6 @@ export class DatafileCache { return fetch(signal); }) .then(() => signal.throwIfAborted()) - .catch((error) => { - if (!signal.aborted) { - this.fail( - error instanceof Error ? error : new Error('Unknown fetch error'), - ); - } - throw error; - }) .finally(() => { // An old, aborted operation must not clear a newer one. if (this.abortController.signal === signal) this.fetching = undefined; @@ -267,16 +262,11 @@ export class DatafileCache { } } - /** Switching sources cancels revalidation without changing storage or failure. */ - cancelFetch(): void { + /** Clearing storage is not recovery; restored seeds keep the failure deadline. */ + clear(): void { this.abortController.abort(); this.abortController = new AbortController(); this.fetching = undefined; - } - - /** Clearing storage is not recovery; restored seeds keep the failure deadline. */ - clear(): void { - this.cancelFetch(); this.data = undefined; this.freshAt = undefined; } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index a0af4d9b0..2a948bbf7 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -191,6 +191,10 @@ export class Controller implements ControllerInterface { this.unauthorized = false; this.cache.updateFromSource(data, 'fetched'); }; + private onHeaderError = (error: Error) => { + // A pending header fetch can finish after stream/poll has taken over. + if (this.state === 'vercel') this.onSourceError(error); + }; // --------------------------------------------------------------------------- // Source event wiring @@ -206,7 +210,7 @@ export class Controller implements ControllerInterface { this.pollingSource.on('data', this.onPollData); this.pollingSource.on('error', this.onSourceError); this.headerSource.on('data', this.onHeaderData); - this.headerSource.on('error', this.onSourceError); + this.headerSource.on('error', this.onHeaderError); } private unwireSourceEvents(): void { @@ -219,7 +223,7 @@ export class Controller implements ControllerInterface { this.pollingSource.off('data', this.onPollData); this.pollingSource.off('error', this.onSourceError); this.headerSource.off('data', this.onHeaderData); - this.headerSource.off('error', this.onSourceError); + this.headerSource.off('error', this.onHeaderError); } // --------------------------------------------------------------------------- @@ -468,12 +472,9 @@ export class Controller implements ControllerInterface { return this.resolveDataForBuildStep(); } - const usingHeaders = this.state === 'vercel'; - if (usingHeaders && !this.headerSource.isAvailable()) { + if (this.state === 'vercel' && !this.headerSource.isAvailable()) { this.headerModeDisabled = true; - this.cache.cancelFetch(); - this.headerSource.stop(); - // The existing fallback chain starts stream/poll; concurrent reads share it. + // New reads share startup; existing header reads finish against the same cache. this.sourceStartup = this.resolveDataWithFallbacks().finally(() => { this.sourceStartup = undefined; }); @@ -485,21 +486,7 @@ export class Controller implements ControllerInterface { } } - let result: [TaggedData, Metrics['cacheStatus']] | undefined; - try { - result = await this.cache.resolve(this.cacheReadPolicy); - } catch (error) { - if ( - !usingHeaders || - !this.headerModeDisabled || - this.state === 'shutdown' - ) { - throw error; - } - // An old header fetch may finish after handover; read from the new source. - if (this.sourceStartup) await this.sourceStartup; - result = await this.cache.resolve(this.cacheReadPolicy); - } + const result = await this.cache.resolve(this.cacheReadPolicy); if (result) return result; return this.resolveDataWithFallbacks(); diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 92d0c731a..3d608c15f 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -301,20 +301,18 @@ describe('Vercel mode (black-box)', () => { }); it.each([ - ['success', 'before'], - ['failure', 'before'], - ['success', 'after'], - ['failure', 'after'], - ] as const)('discards a cancelled header refresh ending in %s %s polling is ready', async (outcome, timing) => { + ['newer', 'before'], + ['older', 'before'], + ['401', 'before'], + ['newer', 'after'], + ['older', 'after'], + ['401', 'after'], + ] as const)('lets a pending header refresh return %s %s polling is ready', async (outcome, timing) => { const pendingHeader = deferred(); dataFetch.mockReturnValueOnce(pendingHeader.promise); const instance = client({ stream: false, staleIfError: 0 }); setVersion(TIMESTAMP + 1); - const settled = vi.fn(); - const originalRead = instance.evaluate('feature').then((result) => { - settled(); - return result; - }); + const originalRead = instance.evaluate('feature'); await vi.advanceTimersByTimeAsync(0); const signal = dataFetch.mock.calls[0]?.[1]?.signal; @@ -322,32 +320,89 @@ describe('Vercel mode (black-box)', () => { const pendingPoll = deferred(); dataFetch.mockReturnValueOnce(pendingPoll.promise); const switchingRead = instance.evaluate('feature'); + const switched = vi.fn(); + void switchingRead.then(switched); await vi.advanceTimersByTimeAsync(0); - expect(signal?.aborted).toBe(true); + expect(signal?.aborted).toBe(false); expect(dataFetch).toHaveBeenCalledTimes(2); if (timing === 'after') { pendingPoll.resolve(Response.json(datafile(TIMESTAMP + 2, true))); - await switchingRead; - } - if (outcome === 'success') { - pendingHeader.resolve(Response.json(datafile(TIMESTAMP + 3))); - } else { - pendingHeader.reject(new Error('late header failure')); + expect(await switchingRead).toMatchObject({ + value: true, + metrics: { mode: 'polling' }, + }); } - await vi.advanceTimersByTimeAsync(0); + + pendingHeader.resolve( + outcome === '401' + ? new Response(null, { status: 401, statusText: 'Unauthorized' }) + : Response.json(datafile(TIMESTAMP + (outcome === 'newer' ? 3 : 1))), + ); + // The original read finishes without waiting for polling or being replayed. + expect(await originalRead).toMatchObject({ + value: timing === 'after' && outcome !== 'newer', + metrics: { cacheStatus: outcome === '401' ? 'STALE' : 'MISS' }, + }); if (timing === 'before') { - expect(settled).not.toHaveBeenCalled(); + expect(switched).not.toHaveBeenCalled(); pendingPoll.resolve(Response.json(datafile(TIMESTAMP + 2, true))); - } - - for (const result of await Promise.all([originalRead, switchingRead])) { - expect(result).toMatchObject({ - value: true, + expect(await switchingRead).toMatchObject({ + value: outcome !== 'newer', metrics: { mode: 'polling' }, }); } - expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); + + expect((await instance.getDatafile()).configUpdatedAt).toBe( + TIMESTAMP + (outcome === 'newer' ? 3 : 2), + ); expect(dataFetch).toHaveBeenCalledTimes(2); + expect(streamFetch).not.toHaveBeenCalled(); + + await instance.shutdown(); + clients.delete(instance); + const events = transport.mock.calls + .filter(([url]) => String(url).endsWith('/v1/ingest')) + .flatMap(([, init]) => JSON.parse(String(init?.body))) as Array<{ + type: string; + payload: { evaluationCount?: number }; + }>; + // A late header 401 must not suppress usage for the active poller. + expect( + events.find(({ type }) => type === 'FLAG_EVALUATION')?.payload + .evaluationCount, + ).toBe(2); + }); + + it('lets a cold header read fail while streaming starts independently', async () => { + const pendingHeader = deferred(); + dataFetch.mockReturnValueOnce(pendingHeader.promise); + const instance = client({ datafile: undefined, staleIfError: 0 }); + const originalRead = instance.evaluate('feature'); + const rejected = expect(originalRead).rejects.toThrow( + 'Failed to fetch data: Unauthorized', + ); + await vi.advanceTimersByTimeAsync(0); + const signal = dataFetch.mock.calls[0]?.[1]?.signal; + + const stream = mockStream(); + streamFetch.mockResolvedValueOnce(stream.response); + setVersion(undefined); + const switchingRead = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(0); + expect(signal?.aborted).toBe(false); + pendingHeader.resolve( + new Response(null, { status: 401, statusText: 'Unauthorized' }), + ); + await rejected; + + stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 1, true) }); + expect(await switchingRead).toMatchObject({ + value: true, + metrics: { mode: 'streaming' }, + }); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(streamFetch).toHaveBeenCalledTimes(1); }); it.each([ From 727158d10c6dfdde5dbae9e0e1964d5cd93980e2 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 30 Sep 2026 17:39:45 +0200 Subject: [PATCH 24/41] refactor(flags-core): unify datafile refresh fallback --- .../controller/datafile-cache-policy.test.ts | 211 ++++++-------- .../src/controller/datafile-cache.test.ts | 67 ++--- .../src/controller/datafile-cache.ts | 104 +++++-- .../src/controller/header-source.ts | 31 +-- .../vercel-flags-core/src/controller/index.ts | 258 +++++++----------- .../src/controller/polling-source.ts | 53 ++-- .../src/controller/stream-source.ts | 8 +- .../src/controller/tagged-data.test.ts | 84 ------ .../src/controller/tagged-data.ts | 15 +- .../src/stale-if-error.test.ts | 64 +++-- .../src/stream-stale-if-error.test.ts | 18 +- .../src/vercel-mode.black-box.test.ts | 184 +++++++------ 12 files changed, 492 insertions(+), 605 deletions(-) delete mode 100644 packages/vercel-flags-core/src/controller/tagged-data.test.ts diff --git a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts index 86e0e66db..b4d674da6 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts @@ -2,11 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DatafileInput } from '../types'; import { getRequestContext } from '../utils/request-context'; import { Authentication } from './auth'; -import { - type CacheReadPolicy, - DatafileCache, - type Freshness, -} from './datafile-cache'; +import { type CacheFetch, DatafileCache } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { HeaderSource } from './header-source'; import { normalizeOptions } from './normalized-options'; @@ -24,6 +20,8 @@ function data(configUpdatedAt = 1): DatafileInput { }; } +const neverSettlingFetch: CacheFetch = () => new Promise(() => {}); + function deferred() { let resolve!: () => void; let reject!: (error: Error) => void; @@ -58,43 +56,16 @@ afterEach(() => { }); describe('cache read callbacks', () => { - it.each([ - 'expired', - 'fresh', - 'stale', - 'unknown', - ] satisfies Freshness[])('serves %s without fetching when fetch is omitted, subject to SIE', async (status) => { - const cache = new DatafileCache(0); - const original = tagData(data(), 'provided'); - cache.seed(original); - const policy = { assess: vi.fn(() => ({ status })) }; - expect(await cache.resolve(policy)).toEqual([ - original, - status === 'fresh' ? 'HIT' : 'STALE', - ]); - const error = new Error('outage'); - cache.fail(error); - await expect(cache.resolve(policy)).rejects.toBe(error); - expect(policy.assess).toHaveBeenCalledTimes(2); - }); - - it('returns undefined without assessing an empty cache when fetch is omitted', async () => { - const cache = new DatafileCache(); - const assess = vi.fn(() => ({ status: 'fresh' as const })); - expect(await cache.resolve({ assess })).toBeUndefined(); - expect(assess).not.toHaveBeenCalled(); - }); - it.each([ 'fresh', 'unknown', ] as const)('serves a %s assessment without fetching or clearing a failure', async (status) => { - const cache = new DatafileCache(0); + const fetch = vi.fn(); + const cache = new DatafileCache(fetch, 0); const original = tagData(data(), 'provided'); cache.seed(original); const policy = { assess: vi.fn(() => ({ status })), - fetch: vi.fn(async () => {}), }; expect(await cache.resolve(policy)).toEqual([ @@ -112,21 +83,21 @@ describe('cache read callbacks', () => { cache.fail(failure); await expect(cache.resolve(policy)).rejects.toBe(failure); expect(policy.assess).toHaveBeenCalledTimes(2); - expect(policy.fetch).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); }); it('blocks expired reads even when no failure exists', async () => { const waitUntil = vi.fn(); - const cache = new DatafileCache(Infinity, waitUntil); - cache.seed(tagData(data(), 'provided')); const pending = deferred(); const fetch = vi.fn(async () => { await pending.promise; - cache.updateFromSource(data(2), 'fetched'); + return data(2); }); + const cache = new DatafileCache(fetch, Infinity, waitUntil); + cache.seed(tagData(data(), 'provided')); const settled = vi.fn(); const reading = cache - .resolve({ assess: () => ({ status: 'expired' as const }), fetch }) + .resolve({ assess: () => ({ status: 'expired' as const }) }) .then(settled); await vi.advanceTimersByTimeAsync(0); expect(settled).not.toHaveBeenCalled(); @@ -138,17 +109,17 @@ describe('cache read callbacks', () => { expect(cache.read()?.configUpdatedAt).toBe(2); }); - it('keeps the first source failure and its inclusive deadline across later attempts', async () => { - const cache = new DatafileCache(100); - const original = tagData(data(), 'provided'); - cache.seed(original); + it('keeps the first source failure and its inclusive deadline without extra attempts', async () => { const firstError = new Error('first outage'); const laterError = new Error('later outage'); const fetch = vi - .fn>() + .fn() .mockRejectedValueOnce(firstError) .mockRejectedValue(laterError); - const policy = { assess: () => ({ status: 'expired' as const }), fetch }; + const cache = new DatafileCache(fetch, 100); + const original = tagData(data(), 'provided'); + cache.seed(original); + const policy = { assess: () => ({ status: 'expired' as const }) }; cache.fail(firstError); expect(await cache.resolve(policy)).toEqual([original, 'STALE']); vi.setSystemTime(1_100); @@ -156,21 +127,19 @@ describe('cache read callbacks', () => { expect(await cache.resolve(policy)).toEqual([original, 'STALE']); vi.setSystemTime(1_101); await expect(cache.resolve(policy)).rejects.toBe(firstError); - expect(fetch).toHaveBeenCalledTimes(3); + expect(fetch).not.toHaveBeenCalled(); }); - it('does not record a rejected fetch as failure without source evidence', async () => { - const cache = new DatafileCache(0); + it('records a rejected cache fetch as source failure', async () => { + const failure = new Error('retired source'); + const fetch = vi.fn().mockRejectedValue(failure); + const cache = new DatafileCache(fetch, 0); const original = tagData(data(), 'provided'); cache.seed(original); - const fetch = vi.fn().mockRejectedValue(new Error('retired source')); - expect( - await cache.resolve({ - assess: () => ({ status: 'expired' as const }), - fetch, - }), - ).toEqual([original, 'STALE']); - expect(cache.read()).toBe(original); + await expect( + cache.resolve({ assess: () => ({ status: 'expired' as const }) }), + ).rejects.toBe(failure); + expect(() => cache.read()).toThrow(failure); expect(fetch).toHaveBeenCalledTimes(1); }); @@ -178,19 +147,13 @@ describe('cache read callbacks', () => { const waitUntil = vi.fn<(promise: Promise) => void>(() => { throw new Error('registration failed'); }); - const cache = new DatafileCache(0, waitUntil); + const failure = new Error('fetch failed'); + const fetch = vi.fn().mockRejectedValue(failure); + const cache = new DatafileCache(fetch, 0, waitUntil); const original = tagData(data(), 'provided'); cache.seed(original); - const failure = new Error('fetch failed'); - const fetch = vi.fn(async () => { - cache.fail(failure); - throw failure; - }); expect( - await cache.resolve({ - assess: () => ({ status: 'stale' as const }), - fetch, - }), + await cache.resolve({ assess: () => ({ status: 'stale' as const }) }), ).toEqual([original, 'STALE']); await waitUntil.mock.calls[0]?.[0]; expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); @@ -205,15 +168,15 @@ describe('cache read callbacks', () => { it('shares a background refresh with a later blocking read', async () => { const waitUntil = vi.fn(); - const cache = new DatafileCache(Infinity, waitUntil); - const original = tagData(data(), 'provided'); - cache.seed(original); const pending = deferred(); const fetch = vi.fn(async () => { await pending.promise; - cache.updateFromSource(data(2), 'fetched'); + return data(2); }); - const policy = { assess: () => ({ status: 'stale' as const }), fetch }; + const cache = new DatafileCache(fetch, Infinity, waitUntil); + const original = tagData(data(), 'provided'); + cache.seed(original); + const policy = { assess: () => ({ status: 'stale' as const }) }; expect(await cache.resolve(policy)).toEqual([original, 'STALE']); expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); const settled = vi.fn(); @@ -235,16 +198,11 @@ describe('cache read callbacks', () => { it('uses the failure deadline even when the callback still permits stale serving', async () => { const waitUntil = vi.fn(); - const cache = new DatafileCache(0, waitUntil); - cache.seed(tagData(data(), 'provided')); const failure = new Error('refresh failed'); - const fetch = vi - .fn>() - .mockImplementationOnce(async () => { - cache.fail(failure); - throw failure; - }); - const policy = { assess: () => ({ status: 'stale' as const }), fetch }; + const fetch = vi.fn().mockRejectedValueOnce(failure); + const cache = new DatafileCache(fetch, 0, waitUntil); + cache.seed(tagData(data(), 'provided')); + const policy = { assess: () => ({ status: 'stale' as const }) }; expect((await cache.resolve(policy))?.[1]).toBe('STALE'); await waitUntil.mock.calls[0]?.[0]; @@ -255,36 +213,30 @@ describe('cache read callbacks', () => { errorSpy.mockClear(); expect(() => cache.read()).toThrow(failure); - fetch.mockImplementationOnce(async () => - cache.updateFromSource(data(2), 'fetched'), - ); - expect((await cache.resolve(policy))?.[1]).toBe('MISS'); - expect(cache.read()?.configUpdatedAt).toBe(2); - expect(fetch).toHaveBeenCalledTimes(2); + fetch.mockResolvedValueOnce(data(2)); + await expect(cache.resolve(policy)).rejects.toBe(failure); + expect(fetch).toHaveBeenCalledTimes(1); expect(waitUntil).toHaveBeenCalledTimes(1); }); it('contains synchronous fetch failures and permits a later retry', async () => { - const cache = new DatafileCache(); const failure = new Error('synchronous failure'); - const fetch = vi.fn>(() => { + const fetch = vi.fn(() => { throw failure; }); - const policy = { assess: () => ({ status: 'expired' as const }), fetch }; + const cache = new DatafileCache(fetch); + const policy = { assess: () => ({ status: 'expired' as const }) }; await expect(cache.resolve(policy)).rejects.toBe(failure); - fetch.mockImplementationOnce(async () => - cache.updateFromSource(data(), 'fetched'), - ); + fetch.mockResolvedValueOnce(data()); expect((await cache.resolve(policy))?.[1]).toBe('MISS'); expect(fetch).toHaveBeenCalledTimes(2); }); it('cancels queued fetch without invoking the callback', async () => { - const cache = new DatafileCache(); - const fetch = vi.fn(async () => {}); + const fetch = vi.fn(); + const cache = new DatafileCache(fetch); const reading = cache.resolve({ assess: () => ({ status: 'expired' as const }), - fetch, }); const outcome = expect(reading).rejects.toThrow(); cache.clear(); @@ -293,21 +245,24 @@ describe('cache read callbacks', () => { }); it('does not let cancelled work fail or clear a newer fetch', async () => { - const cache = new DatafileCache(0); - cache.seed(tagData(data(), 'provided')); const oldPending = deferred(); const nextPending = deferred(); const fetch = vi - .fn>() - .mockImplementationOnce(() => oldPending.promise) + .fn() + .mockImplementationOnce(async () => { + await oldPending.promise; + return data(2); + }) .mockImplementationOnce(async (signal) => { await nextPending.promise; signal.throwIfAborted(); - cache.updateFromSource(data(2), 'fetched'); + return data(2); }); - const policy = { assess: () => ({ status: 'expired' as const }), fetch }; + const cache = new DatafileCache(fetch, 0); + cache.seed(tagData(data(), 'provided')); + const policy = { assess: () => ({ status: 'expired' as const }) }; const oldRead = cache.resolve(policy); - const cancelled = expect(oldRead).rejects.toThrow('cancelled transport'); + const cancelled = expect(oldRead).rejects.toThrow(); await vi.advanceTimersByTimeAsync(0); const oldSignal = fetch.mock.calls[0]?.[0]; cache.clear(); @@ -414,7 +369,7 @@ describe('header freshness policy', () => { }); it('resets cache age on an equal highest-observed header, then blocks older confirmations until stop', async () => { - const cache = new DatafileCache(0); + const cache = new DatafileCache(neverSettlingFetch, 0); const original = Object.freeze( tagData({ ...data(), fetchedAt: 500 }, 'bundled'), ); @@ -465,7 +420,6 @@ describe('header freshness policy', () => { }); it('assesses the captured raw header after a shared cold fetch discovers the project', async () => { - const cache = new DatafileCache(0); const headerSource = source(); const headers = { 'x-vercel-flags-config-versions': 'flags_prj_policy=2' }; vi.mocked(getRequestContext).mockReturnValue({ ctx: undefined, headers }); @@ -473,12 +427,13 @@ describe('header freshness policy', () => { const pending = deferred(); const fetch = vi.fn(async () => { await pending.promise; - cache.updateFromSource(data(), 'fetched'); + return data(); }); - const firstRead = cache.resolve({ assess: originalCheck, fetch }); + const cache = new DatafileCache(fetch, 0); + const firstRead = cache.resolve({ assess: originalCheck }); headers['x-vercel-flags-config-versions'] = 'flags_prj_policy=1'; const laterCheck = vi.fn(headerSource.getAssessment()); - const secondRead = cache.resolve({ assess: laterCheck, fetch }); + const secondRead = cache.resolve({ assess: laterCheck }); await vi.advanceTimersByTimeAsync(0); expect(originalCheck).not.toHaveBeenCalled(); expect(laterCheck).not.toHaveBeenCalled(); @@ -525,15 +480,23 @@ describe('header freshness policy', () => { ).toEqual({ status: 'expired' }); }); - it('emits raw fetched data and confirms equal responses without changing fetchedAt', async () => { - const cache = new DatafileCache(); + it('accepts fetched data directly and confirms equal responses without changing fetchedAt', async () => { + const cache = new DatafileCache( + (signal) => + fetchDatafile({ + ...normalizeOptions({ + auth: new Authentication(undefined), + vercel: true, + }), + signal, + }), + Infinity, + ); const original = Object.freeze( tagData({ ...data(), fetchedAt: 500 }, 'bundled'), ); cache.seed(original); const headerSource = source(); - const onData = vi.fn((raw) => cache.updateFromSource(raw, 'fetched')); - headerSource.on('data', onData); const incoming = Object.freeze({ ...data(), configUpdatedAt: 1, @@ -541,14 +504,12 @@ describe('header freshness policy', () => { digest: 'test', }); vi.mocked(fetchDatafile).mockResolvedValue(incoming); - const fetch = headerSource.fetch; - const signal = new AbortController().signal; vi.setSystemTime(2_000); - await fetch(signal); - expect(onData).toHaveBeenCalledExactlyOnceWith(incoming); - expect(onData.mock.calls[0]?.[0]).toBe(incoming); + expect( + await cache.resolve({ assess: () => ({ status: 'expired' }) }), + ).toEqual([original, 'MISS']); expect(fetchDatafile).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ signal }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), ); expect(cache.read()).toBe(original); expect(cache.ageMs).toBe(0); @@ -564,18 +525,25 @@ describe('header freshness policy', () => { }); it('suppresses a successful transport response after cache clear cancels the fetch', async () => { - const cache = new DatafileCache(0); const headerSource = source(); const pending = deferred(); vi.mocked(fetchDatafile).mockImplementation(async () => { await pending.promise; return { ...data(2), configUpdatedAt: 2, revision: 2, digest: 'test' }; }); - const onData = vi.fn((raw) => cache.updateFromSource(raw, 'fetched')); - headerSource.on('data', onData); + const cache = new DatafileCache( + (signal) => + fetchDatafile({ + ...normalizeOptions({ + auth: new Authentication(undefined), + vercel: true, + }), + signal, + }), + 0, + ); const reading = cache.resolve({ assess: assessment(headerSource, 'flags_prj_policy=2'), - fetch: headerSource.fetch, }); const outcome = expect(reading).rejects.toThrow(); await vi.advanceTimersByTimeAsync(0); @@ -585,7 +553,6 @@ describe('header freshness policy', () => { pending.resolve(); await outcome; expect(signal?.aborted).toBe(true); - expect(onData).not.toHaveBeenCalled(); expect(cache.read()).toBeUndefined(); expect(cache.ageMs).toBe(Infinity); const replacement = tagData(data(), 'provided'); diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache.test.ts index a4f8a6edc..23d0cee37 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DatafileInput } from '../types'; +import type { CacheFetch } from './datafile-cache'; import { DatafileCache } from './datafile-cache'; import type { TaggedData } from './tagged-data'; @@ -18,6 +19,10 @@ function data(_origin: TaggedData['_origin'] = 'provided'): TaggedData { return { ...response(), _origin }; } +const unexpectedFetch: CacheFetch = async () => { + throw new Error('Unexpected cache fetch'); +}; + let errorSpy: ReturnType; let warnSpy: ReturnType; @@ -43,7 +48,7 @@ describe('DatafileCache', () => { undefined, 500, ])('resets age without changing stored fetchedAt %s or data', (fetchedAt) => { - const cache = new DatafileCache(); + const cache = new DatafileCache(unexpectedFetch); const original = Object.freeze({ ...data('bundled'), fetchedAt }); cache.seed(original); vi.setSystemTime(2_000); @@ -59,7 +64,7 @@ describe('DatafileCache', () => { it.each([ 0, 100, ])('preserves the first failure and deadline when resetting age with SIE %s, including after expiry', (staleIfErrorMs) => { - const cache = new DatafileCache(staleIfErrorMs); + const cache = new DatafileCache(unexpectedFetch, staleIfErrorMs); const original = Object.freeze({ ...data(), fetchedAt: 500 }); cache.seed(original); const firstError = new Error('first outage'); @@ -95,7 +100,7 @@ describe('DatafileCache', () => { }); it('does not establish age while empty or make a later unknown-age seed fresh', () => { - const cache = new DatafileCache(); + const cache = new DatafileCache(unexpectedFetch); cache.resetAge(); expect(cache.ageMs).toBe(Infinity); expect(cache.read()).toBeUndefined(); @@ -109,7 +114,7 @@ describe('DatafileCache', () => { it.each([ 0, 500, 1_000, 2_000, ])('restores age from persisted fetchedAt %s without changing storage', (fetchedAt) => { - const cache = new DatafileCache(); + const cache = new DatafileCache(unexpectedFetch); const original = Object.freeze({ ...data(), fetchedAt }); cache.seed(original); expect(cache.ageMs).toBe(Math.max(0, 1_000 - fetchedAt)); @@ -129,7 +134,7 @@ describe('DatafileCache', () => { '500', null, ])('treats invalid or missing fetchedAt %s as unknown age', (fetchedAt) => { - const cache = new DatafileCache(); + const cache = new DatafileCache(unexpectedFetch); cache.updateFromSource(response(), 'fetched'); expect(cache.ageMs).toBe(0); const original = Object.freeze({ ...data(), fetchedAt }) as TaggedData; @@ -145,7 +150,7 @@ describe('DatafileCache', () => { 'configUpdatedAt', 'revision', ] as const)('resets age on valid %s confirmation while retaining fetchedAt and origin', (version) => { - const cache = new DatafileCache(); + const cache = new DatafileCache(unexpectedFetch); const original = Object.freeze({ ...data('bundled'), revision: 42, @@ -164,7 +169,7 @@ describe('DatafileCache', () => { }); it('clears storage and age without clearing the first failure', () => { - const cache = new DatafileCache(100); + const cache = new DatafileCache(unexpectedFetch, 100); cache.updateFromSource(response(), 'fetched'); const error = new Error('first outage'); cache.fail(error); @@ -191,7 +196,7 @@ describe('DatafileCache', () => { const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { throw new Error('Unexpected fetch'); }); - const cache = new DatafileCache(staleIfErrorMs); + const cache = new DatafileCache(unexpectedFetch, staleIfErrorMs); expect(cache.hasData).toBe(false); expect(cache.ageMs).toBe(Infinity); expect(cache.revision).toBeUndefined(); @@ -216,7 +221,7 @@ describe('DatafileCache', () => { }); it('stores data without an age-based expiry when no failure exists', () => { - const cache = new DatafileCache(0); + const cache = new DatafileCache(unexpectedFetch, 0); const original = data(); expect(cache.seed(original)).toBeUndefined(); expect(cache.read()).toBe(original); @@ -226,7 +231,7 @@ describe('DatafileCache', () => { }); it('starts the inclusive allowance at the first failure, not storage time', () => { - const cache = new DatafileCache(100); + const cache = new DatafileCache(unexpectedFetch, 100); const original = { ...data('poll'), revision: 42 }; cache.seed(original); vi.setSystemTime(2_000); @@ -249,7 +254,7 @@ describe('DatafileCache', () => { }); it.each([0, -1])('fails immediately with policy %s', (staleIfErrorMs) => { - const cache = new DatafileCache(staleIfErrorMs); + const cache = new DatafileCache(unexpectedFetch, staleIfErrorMs); const original = data(); cache.seed(original); expect(cache.read()).toBe(original); @@ -265,7 +270,7 @@ describe('DatafileCache', () => { undefined, Infinity, ])('allows unlimited stale reads with policy %s', (staleIfErrorMs) => { - const cache = new DatafileCache(staleIfErrorMs); + const cache = new DatafileCache(unexpectedFetch, staleIfErrorMs); const original = data(); cache.seed(original); cache.fail(new Error('poll failed')); @@ -277,7 +282,7 @@ describe('DatafileCache', () => { }); it('does not clear or renew failure when seeding network data', () => { - const cache = new DatafileCache(100); + const cache = new DatafileCache(unexpectedFetch, 100); cache.seed(data('poll')); const error = new Error('poll failed'); cache.fail(error); @@ -293,7 +298,7 @@ describe('DatafileCache', () => { 'provided', 'bundled', ] as const)('restores %s seeds only within the original failure deadline', (origin) => { - const cache = new DatafileCache(100); + const cache = new DatafileCache(unexpectedFetch, 100); const seed = data(origin); cache.seed(seed); const firstError = new Error('first poll failed'); @@ -318,7 +323,7 @@ describe('DatafileCache', () => { }); it('clears failure on a matching raw source response without replacing data', () => { - const cache = new DatafileCache(100); + const cache = new DatafileCache(unexpectedFetch, 100); const original = data(); cache.seed(original); const incoming = response(); @@ -346,7 +351,7 @@ describe('DatafileCache', () => { ['1', '1'], [0, '0'], ])('confirms equal finite versions (%s and %s)', (current, incoming) => { - const cache = new DatafileCache(0); + const cache = new DatafileCache(unexpectedFetch, 0); const original = { ...data(), configUpdatedAt: current }; cache.seed(original); const error = new Error('poll failed'); @@ -375,7 +380,7 @@ describe('DatafileCache', () => { string, Partial, ][])('rejects %s without changing storage or the failure deadline', (_, overrides) => { - const cache = new DatafileCache(100); + const cache = new DatafileCache(unexpectedFetch, 100); const original = Object.freeze({ ...data(), fetchedAt: 500 }); cache.seed(original); const error = new Error('first outage'); @@ -401,7 +406,7 @@ describe('DatafileCache', () => { 'Infinity', -Infinity, ])('never confirms invalid current version %s, even for the same object', (configUpdatedAt) => { - const cache = new DatafileCache(100); + const cache = new DatafileCache(unexpectedFetch, 100); const original = { ...data(), configUpdatedAt }; cache.seed(original); const error = new Error('poll failed'); @@ -425,7 +430,7 @@ describe('DatafileCache', () => { NaN, Infinity, ])('retains failure when seeding a replacement with version %s', (configUpdatedAt) => { - const cache = new DatafileCache(staleIfErrorMs); + const cache = new DatafileCache(unexpectedFetch, staleIfErrorMs); cache.seed(data()); const error = new Error('poll failed'); cache.fail(error); @@ -449,7 +454,7 @@ describe('DatafileCache', () => { }); it('rejects an old response after storing a newer replacement', () => { - const cache = new DatafileCache(100); + const cache = new DatafileCache(unexpectedFetch, 100); cache.seed(data()); const oldResponse = response(); const replacement = { ...data('poll'), configUpdatedAt: 2 }; @@ -467,7 +472,7 @@ describe('DatafileCache', () => { it.each([ 0, 42, ])('confirms revision %s after expiry without replacing data and starts a fresh allowance', (revision) => { - const cache = new DatafileCache(100); + const cache = new DatafileCache(unexpectedFetch, 100); const original = Object.freeze({ ...data('bundled'), revision }); cache.seed(original); expect(cache.read()).toBe(original); @@ -517,7 +522,7 @@ describe('DatafileCache', () => { ['infinite revision', { revision: Infinity }], ['negative infinite revision', { revision: -Infinity }], ])('rejects %s without replacing data or changing the failure deadline', (_, overrides) => { - const cache = new DatafileCache(100); + const cache = new DatafileCache(unexpectedFetch, 100); const original = Object.freeze({ ...data('bundled'), revision: 42, @@ -563,7 +568,7 @@ describe('DatafileCache', () => { Infinity, -Infinity, ])('never confirms invalid stored revision %s, even for the same object', (revision) => { - const cache = new DatafileCache(100); + const cache = new DatafileCache(unexpectedFetch, 100); const original = Object.freeze({ ...data('bundled'), revision, @@ -592,7 +597,7 @@ describe('DatafileCache', () => { 'stream', 'fetched', ] as const)('accepts the first %s response and clears a failure recorded while empty', (origin) => { - const cache = new DatafileCache(0); + const cache = new DatafileCache(unexpectedFetch, 0); cache.fail(new Error('failed before data arrived')); vi.setSystemTime(2_000); const incoming = response({ configUpdatedAt: NaN }); @@ -631,7 +636,7 @@ describe('DatafileCache', () => { [-Infinity, 0], [Infinity, undefined], ])('accepts version %s → %s and automatically starts a fresh allowance on the next failure', (current, next) => { - const cache = new DatafileCache(100); + const cache = new DatafileCache(unexpectedFetch, 100); cache.seed({ ...data('bundled'), configUpdatedAt: current }); const firstError = new Error('first outage'); cache.fail(firstError); @@ -665,7 +670,7 @@ describe('DatafileCache', () => { { projectId: 'prj_other' }, { environment: 'preview' }, ])('preserves acceptance of a newer version despite mismatched identity %j', (overrides) => { - const cache = new DatafileCache(0); + const cache = new DatafileCache(unexpectedFetch, 0); cache.seed(data('bundled')); cache.fail(new Error('outage')); const incoming = response({ ...overrides, configUpdatedAt: 2 }); @@ -688,7 +693,7 @@ describe('DatafileCache', () => { [0, '0'], ['0', 0], ])('confirms equal finite versions %s and %s without tagging or replacing either object', (current, next) => { - const cache = new DatafileCache(100); + const cache = new DatafileCache(unexpectedFetch, 100); const original = Object.freeze({ ...data('bundled'), configUpdatedAt: current, @@ -715,7 +720,7 @@ describe('DatafileCache', () => { 'reused', 'distinct', ])('confirms a %s tagged response without changing its origin', (kind) => { - const cache = new DatafileCache(0); + const cache = new DatafileCache(unexpectedFetch, 0); const original = Object.freeze(data('bundled')); cache.seed(original); const incoming = @@ -744,7 +749,7 @@ describe('DatafileCache', () => { string, Partial, ][])('rejects %s without mutation or clearing the original error/deadline', (_, overrides) => { - const cache = new DatafileCache(staleIfErrorMs); + const cache = new DatafileCache(unexpectedFetch, staleIfErrorMs); const original = Object.freeze(data('bundled')); cache.seed(original); expect(cache.read()).toBe(original); @@ -786,7 +791,7 @@ describe('DatafileCache', () => { [-Infinity, -Infinity], [-Infinity, '-Infinity'], ])('cannot recover from a rejected response with nonfinite current version %s and incoming version %s', (current, next) => { - const cache = new DatafileCache(staleIfErrorMs); + const cache = new DatafileCache(unexpectedFetch, staleIfErrorMs); const original = Object.freeze({ ...data('bundled'), configUpdatedAt: current, @@ -820,7 +825,7 @@ describe('DatafileCache', () => { }); it('rejects an old response after an accepted replacement without renewing its failure deadline', () => { - const cache = new DatafileCache(staleIfErrorMs); + const cache = new DatafileCache(unexpectedFetch, staleIfErrorMs); const oldResponse = Object.freeze(data('bundled')); cache.seed(oldResponse); const replacement = response({ configUpdatedAt: 2 }); diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index f022c643d..a1c103084 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -16,17 +16,14 @@ export type CacheAssessment = { confirmed?: boolean; }; -type Fetch = (signal: AbortSignal) => Promise; +export type CacheFetch = (signal: AbortSignal) => Promise; type CacheResult = [TaggedData, Metrics['cacheStatus']]; export type CacheReadPolicy = { /** Unknown adds no freshness evidence and keeps cached-read behavior. */ assess: (data: CacheMetadata) => CacheAssessment; - /** - * Source events must report data/failure before this settles. - * Omit for modes whose stream/poll loop already maintains the cache. - */ - fetch?: Fetch; + /** Header reads can provide new recovery evidence before a source update. */ + retryOnFailure?: boolean; }; /** @@ -51,10 +48,13 @@ export class DatafileCache { private abortController = new AbortController(); private fetching: Promise | undefined; + private timedOutFetch: Promise | undefined; constructor( + private readonly fetch: CacheFetch, private readonly staleIfErrorMs = Infinity, private readonly waitUntil: WaitUntil = () => {}, + private readonly fetchTimeoutMs = 0, ) {} /** Expired data still exists; fallback loading must not bypass its failure policy. */ @@ -106,7 +106,7 @@ export class DatafileCache { /** Accepts a source update or confirms the current version without replacing it. */ updateFromSource(incoming: DatafileInput, origin: DataOrigin): void { if (this.isNewerData(incoming)) { - this.data = tagData(incoming, origin); + this.data = tagData({ ...incoming, fetchedAt: Date.now() }, origin); this.resetAge(); this.failure = undefined; return; @@ -189,31 +189,76 @@ export class DatafileCache { const { status, confirmed } = policy.assess(metadata); // Apply recovery evidence before read() enforces the failure deadline. if (confirmed) this.confirm(); - if (status === 'fresh' || status === 'unknown' || !policy.fetch) { - // Stream/poll omit fetch because they maintain the cache independently. + if (status === 'fresh' || status === 'unknown') { // read() still enforces stale-if-error, even for a fresh assessment. return [this.read()!, status === 'fresh' ? 'HIT' : 'STALE']; } + if (this.failure) { + if (!policy.retryOnFailure) return [this.read()!, 'STALE']; + if (this.canServe()) { + const stale = this.read()!; + this.fetchInBackground(); + return [stale, 'STALE']; + } + } + + // A blocking refresh that already timed out keeps running in the + // background. Do not repeatedly block on the same request. + if (this.fetching && this.timedOutFetch === this.fetching) { + return [this.read()!, 'STALE']; + } + // If stale-if-error has expired, fall through to a blocking recovery fetch. // Calling read() here would throw before a background fetch could start. if (status === 'stale' && this.canServe()) { const stale = this.read()!; - this.fetchInBackground(policy.fetch); + this.fetchInBackground(); return [stale, 'STALE']; } } - if (!policy.fetch) return; - - const { promise, signal } = this.startFetch(policy.fetch); + const { promise, signal } = this.startFetch('fetched'); try { - await promise; + if (this.fetchTimeoutMs > 0) { + let timeoutId: ReturnType; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout( + () => + reject(new Error('@vercel/flags-core: Datafile refresh timeout')), + this.fetchTimeoutMs, + ); + }); + try { + await Promise.race([promise, timeout]); + } finally { + clearTimeout(timeoutId!); + } + } else { + await promise; + } signal.throwIfAborted(); } catch (error) { if (signal.aborted) throw error; const stale = this.read(); if (!stale) throw error; + if ( + error instanceof Error && + error.message === '@vercel/flags-core: Datafile refresh timeout' + ) { + this.timedOutFetch = this.fetching; + console.warn( + '@vercel/flags-core: Datafile refresh timeout, serving stale while refresh continues in the background', + ); + } + if (this.fetching) { + const background = this.fetching.catch(() => {}); + try { + this.waitUntil(background); + } catch { + // Registration is best-effort; the handled refresh continues. + } + } return [stale, 'STALE']; } @@ -229,7 +274,12 @@ export class DatafileCache { return [data, 'MISS']; } - private startFetch(fetch: Fetch) { + /** Runs the one shared datafile refresh used by reads and polling. */ + refresh(origin: DataOrigin = 'fetched'): Promise { + return this.startFetch(origin).promise; + } + + private startFetch(origin: DataOrigin) { const { signal } = this.abortController; // Share the fetch, but let each caller assess its own request's headers. if (this.fetching) return { promise: this.fetching, signal }; @@ -237,19 +287,32 @@ export class DatafileCache { const promise = Promise.resolve() .then(() => { signal.throwIfAborted(); - return fetch(signal); + return this.fetch(signal); + }) + .then((data) => { + signal.throwIfAborted(); + this.updateFromSource(data, origin); + }) + .catch((error) => { + signal.throwIfAborted(); + const err = + error instanceof Error ? error : new Error('Unknown fetch error'); + this.fail(err); + throw err; }) - .then(() => signal.throwIfAborted()) .finally(() => { // An old, aborted operation must not clear a newer one. - if (this.abortController.signal === signal) this.fetching = undefined; + if (this.abortController.signal === signal) { + if (this.timedOutFetch === promise) this.timedOutFetch = undefined; + this.fetching = undefined; + } }); this.fetching = promise; return { promise, signal }; } - private fetchInBackground(fetch: Fetch): void { - const { promise, signal } = this.startFetch(fetch); + private fetchInBackground(): void { + const { promise, signal } = this.startFetch('fetched'); const background = promise.catch((error) => { if (!signal.aborted) { console.error('@vercel/flags-core: Revalidation failed:', error); @@ -267,6 +330,7 @@ export class DatafileCache { this.abortController.abort(); this.abortController = new AbortController(); this.fetching = undefined; + this.timedOutFetch = undefined; this.data = undefined; this.freshAt = undefined; } diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 695a19b18..2d2212694 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -1,22 +1,12 @@ -import type { DatafileInput } from '../types'; import { getRequestContext } from '../utils/request-context'; import type { CacheReadPolicy } from './datafile-cache'; -import { fetchDatafile } from './fetch-datafile'; import type { NormalizedOptions } from './normalized-options'; -import { TypedEmitter } from './typed-emitter'; -export type HeaderSourceEvents = { - data: (data: DatafileInput) => void; - error: (error: Error) => void; -}; - -/** Request version evidence and fetching; the cache decides how to serve reads. */ -export class HeaderSource extends TypedEmitter { +/** Request version evidence; the cache decides how to serve reads. */ +export class HeaderSource { private highestObserved = 0; - constructor(private readonly options: NormalizedOptions) { - super(); - } + constructor(private readonly options: NormalizedOptions) {} private getVersionHeader(): string | undefined { const { headers } = getRequestContext(); @@ -77,21 +67,6 @@ export class HeaderSource extends TypedEmitter { return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : undefined; } - fetch = async (signal: AbortSignal): Promise => { - try { - const data = await fetchDatafile({ ...this.options, signal }); - // Transports can finish after cancellation; never publish that response. - signal.throwIfAborted(); - this.emit('data', data); - } catch (error) { - signal.throwIfAborted(); - const err = - error instanceof Error ? error : new Error('Unknown header error'); - this.emit('error', err); - throw err; - } - }; - isEnabled(): boolean { // Explicit offline mode disables header-driven refreshes too. return ( diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 2a948bbf7..e8c4a99fc 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -29,6 +29,10 @@ export type { ControllerOptions } from './normalized-options'; export { PollingSource } from './polling-source'; export { StreamSource } from './stream-source'; +function tagFetchedData(data: DatafileInput): TaggedData { + return tagData({ ...data, fetchedAt: Date.now() }, 'fetched'); +} + // --------------------------------------------------------------------------- // Internal types // --------------------------------------------------------------------------- @@ -49,6 +53,8 @@ type State = | 'build:ready' | 'shutdown'; +type RuntimeSource = 'header' | 'stream' | 'polling'; + // --------------------------------------------------------------------------- // Controller // --------------------------------------------------------------------------- @@ -102,10 +108,6 @@ export class Controller implements ControllerInterface { private pollingSource: PollingSource; private bundledSource: BundledSource; private headerSource: HeaderSource; - private headerModeDisabled = false; - private sourceStartup: - | Promise<[TaggedData, Metrics['cacheStatus']]> - | undefined; // Usage tracking private usageTracker: UsageTracker; @@ -122,8 +124,21 @@ export class Controller implements ControllerInterface { constructor(options: ControllerOptions) { this.options = normalizeOptions(options); this.cache = new DatafileCache( + async (signal) => { + try { + const data = await fetchDatafile({ ...this.options, signal }); + this.unauthorized = false; + return data; + } catch (error) { + this.noteUnauthorized(error); + throw error; + } + }, this.options.staleIfErrorMs, this.options.waitUntil, + this.options.polling.enabled + ? this.options.polling.initTimeoutMs + : this.options.stream.initTimeoutMs, ); // Create source modules @@ -132,7 +147,11 @@ export class Controller implements ControllerInterface { () => this.cache.revision, ); - this.pollingSource = new PollingSource(this.options); + this.pollingSource = new PollingSource({ + polling: this.options.polling, + staleWhileRevalidateMs: this.options.staleWhileRevalidateMs, + refresh: () => this.cache.refresh('poll'), + }); this.headerSource = new HeaderSource(this.options); this.bundledSource = new BundledSource({ @@ -145,7 +164,7 @@ export class Controller implements ControllerInterface { // If datafile provided, use it immediately if (this.options.datafile) { - this.cache.seed(tagData(this.options.datafile, 'provided')); + this.cache.seed(tagData({ ...this.options.datafile }, 'provided')); } this.usageTracker = new UsageTracker(this.options); @@ -169,7 +188,13 @@ export class Controller implements ControllerInterface { this.cache.confirm(); }; private onStreamConnected = () => { - if (this.state === 'degraded' || this.state === 'initializing:stream') { + if (this.state === 'polling') { + this.pollingSource.stop(); + this.transition('streaming'); + } else if ( + this.state === 'degraded' || + this.state === 'initializing:stream' + ) { this.transition('streaming'); } }; @@ -177,24 +202,13 @@ export class Controller implements ControllerInterface { this.cache.fail(new Error('stream: disconnected')); if (this.state === 'streaming') { this.transition('degraded'); + void this.activateFallbackSource('stream'); } }; private onSourceError = (error: Error) => { this.noteUnauthorized(error); this.cache.fail(error); }; - private onPollData = (data: DatafileInput) => { - this.unauthorized = false; - this.cache.updateFromSource(data, 'poll'); - }; - private onHeaderData = (data: DatafileInput) => { - this.unauthorized = false; - this.cache.updateFromSource(data, 'fetched'); - }; - private onHeaderError = (error: Error) => { - // A pending header fetch can finish after stream/poll has taken over. - if (this.state === 'vercel') this.onSourceError(error); - }; // --------------------------------------------------------------------------- // Source event wiring @@ -207,10 +221,7 @@ export class Controller implements ControllerInterface { this.streamSource.on('connected', this.onStreamConnected); this.streamSource.on('disconnected', this.onStreamDisconnected); this.streamSource.on('error', this.onSourceError); - this.pollingSource.on('data', this.onPollData); this.pollingSource.on('error', this.onSourceError); - this.headerSource.on('data', this.onHeaderData); - this.headerSource.on('error', this.onHeaderError); } private unwireSourceEvents(): void { @@ -220,10 +231,7 @@ export class Controller implements ControllerInterface { this.streamSource.off('connected', this.onStreamConnected); this.streamSource.off('disconnected', this.onStreamDisconnected); this.streamSource.off('error', this.onSourceError); - this.pollingSource.off('data', this.onPollData); this.pollingSource.off('error', this.onSourceError); - this.headerSource.off('data', this.onHeaderData); - this.headerSource.off('error', this.onHeaderError); } // --------------------------------------------------------------------------- @@ -234,6 +242,10 @@ export class Controller implements ControllerInterface { this.state = to; } + private get isShutdown(): boolean { + return this.state === 'shutdown'; + } + private get mode(): Metrics['mode'] { if (this.options.buildStep) return 'build'; switch (this.state) { @@ -271,7 +283,7 @@ export class Controller implements ControllerInterface { // Hydrate from provided datafile if not already set (e.g., after shutdown) if (!this.cache.hasData && this.options.datafile) { - this.cache.seed(tagData(this.options.datafile, 'provided')); + this.cache.seed(tagData({ ...this.options.datafile }, 'provided')); } // If no data yet, try loading bundled definitions eagerly so we can @@ -280,54 +292,24 @@ export class Controller implements ControllerInterface { if (!this.cache.hasData) { try { const bundled = await this.bundledSource.tryLoad(); - if (bundled) this.cache.seed(tagData(bundled, 'bundled')); + if (bundled) this.cache.seed(tagData({ ...bundled }, 'bundled')); } catch { // Bundled definitions not available — proceed without revision } } // Select header mode after hydration so provided/bundled data avoids a cold fetch. - if (this.headerSource.isEnabled() && !this.headerModeDisabled) { + if (this.headerSource.isEnabled()) { this.transition('vercel'); return; } - // If we already have data (from provided datafile or bundled definitions), - // start updates. Both streaming and polling wait for initial data before - // being considered initialized, so we know we have fresh data. - // For no-updates (offline), return immediately since we already have usable data. - if (this.cache.hasData) { - if (this.options.stream.enabled) { - this.transition('initializing:stream'); - await this.tryInitializeStream(); - } else if (this.options.polling.enabled) { - this.transition('initializing:polling'); - await this.tryInitializePolling(); - } else { - this.transition('degraded'); - } - return; - } - - // Try the configured primary source (stream or poll, never both) - if (this.options.stream.enabled) { - this.transition('initializing:stream'); - const streamSuccess = await this.tryInitializeStream(); - if (streamSuccess) { - this.transition('streaming'); - return; - } - } else if (this.options.polling.enabled) { - this.transition('initializing:polling'); - const pollingSuccess = await this.tryInitializePolling(); - if (pollingSuccess) { - this.transition('polling'); - return; - } - } + await this.activateFallbackSource('header'); + if (this.cache.hasData) return; - // Fallback chain: datafile → bundled → one-time fetch (offline only) - await this.initializeFromFallbacks(); + // All update sources share the same final blocking datafile fetch. + const fetched = await this.cache.resolve(this.cacheReadPolicy); + if (!fetched) await this.initializeFromFallbacks(); } /** @@ -375,7 +357,7 @@ export class Controller implements ControllerInterface { this.headerSource.stop(); this.cache.clear(); if (this.options.datafile) { - this.cache.seed(tagData(this.options.datafile, 'provided')); + this.cache.seed(tagData({ ...this.options.datafile }, 'provided')); } this.transition('shutdown'); await this.usageTracker.shutdown(); @@ -408,7 +390,7 @@ export class Controller implements ControllerInterface { // Preserve snapshot loading without starting stream/poll initialization. const bundled = await this.bundledSource.tryLoad(); if (bundled) { - this.cache.seed(tagData(bundled, 'bundled')); + this.cache.seed(tagData({ ...bundled }, 'bundled')); } else { try { const fetched = await fetchDatafile({ @@ -416,7 +398,7 @@ export class Controller implements ControllerInterface { auth: this.options.auth, fetch: this.options.fetch, }); - this.cache.seed(tagData(fetched, 'fetched')); + this.cache.seed(tagFetchedData(fetched)); } catch (error) { this.noteUnauthorized(error); throw this.noDefinitionsError( @@ -473,17 +455,11 @@ export class Controller implements ControllerInterface { } if (this.state === 'vercel' && !this.headerSource.isAvailable()) { - this.headerModeDisabled = true; - // New reads share startup; existing header reads finish against the same cache. - this.sourceStartup = this.resolveDataWithFallbacks().finally(() => { - this.sourceStartup = undefined; - }); - } - if (this.sourceStartup) { - await this.sourceStartup; - if (this.state === 'shutdown') { - throw new Error('@vercel/flags-core: Client is shut down'); - } + await this.activateFallbackSource('header'); + } else if (this.state === 'initializing:stream') { + await this.activateFallbackSource('header'); + } else if (this.state === 'initializing:polling') { + await this.activateFallbackSource('stream'); } const result = await this.cache.resolve(this.cacheReadPolicy); @@ -496,7 +472,7 @@ export class Controller implements ControllerInterface { if (this.state === 'vercel') { return { assess: this.headerSource.getAssessment(), - fetch: this.headerSource.fetch, + retryOnFailure: true, }; } @@ -512,6 +488,36 @@ export class Controller implements ControllerInterface { return { assess: () => ({ status: 'unknown' }) }; } + /** + * Advances through the runtime source chain. Every caller uses the same path: + * request headers → stream → polling → direct cache refresh. + */ + private async activateFallbackSource(after: RuntimeSource): Promise { + if (this.state === 'shutdown') { + throw new Error('@vercel/flags-core: Client is shut down'); + } + + if (after === 'header' && this.options.stream.enabled) { + this.transition('initializing:stream'); + if (await this.tryInitializeStream()) { + if (!this.isShutdown) this.transition('streaming'); + return; + } + after = 'stream'; + } + + if ( + (after === 'header' || after === 'stream') && + this.options.polling.enabled + ) { + this.pollingSource.startInterval(); + this.transition('polling'); + return; + } + + this.transition('degraded'); + } + // --------------------------------------------------------------------------- // Stream initialization // --------------------------------------------------------------------------- @@ -566,63 +572,6 @@ export class Controller implements ControllerInterface { } } - // --------------------------------------------------------------------------- - // Polling initialization - // --------------------------------------------------------------------------- - - /** - * Attempts to initialize via polling with timeout. - * Returns true if first poll succeeded within timeout. - * - * Only used when streaming is disabled and polling is the primary source. - */ - private async tryInitializePolling(): Promise { - const pollPromise = this.pollingSource.poll(); - - if (this.options.polling.initTimeoutMs <= 0) { - try { - await pollPromise; - if (this.state !== 'shutdown' && this.cache.hasData) { - this.pollingSource.startInterval(); - return true; - } - return false; - } catch { - return false; - } - } - - // Race against timeout - let timeoutId: ReturnType; - const timeoutPromise = new Promise<'timeout'>((resolve) => { - timeoutId = setTimeout( - () => resolve('timeout'), - this.options.polling.initTimeoutMs, - ); - }); - - try { - const result = await Promise.race([pollPromise, timeoutPromise]); - clearTimeout(timeoutId!); - - if (result === 'timeout') { - console.warn( - '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', - ); - return false; - } - - if (this.state !== 'shutdown' && this.cache.hasData) { - this.pollingSource.startInterval(); - return true; - } - return false; - } catch { - clearTimeout(timeoutId!); - return false; - } - } - private noteUnauthorized(error: unknown): void { if ( error instanceof UnauthorizedError || @@ -681,7 +630,7 @@ export class Controller implements ControllerInterface { */ private async loadBuildData(): Promise { const bundled = await this.bundledSource.tryLoad(); - if (bundled) return tagData(bundled, 'bundled'); + if (bundled) return tagData({ ...bundled }, 'bundled'); // Fallback: one-time fetch try { @@ -690,7 +639,7 @@ export class Controller implements ControllerInterface { auth: this.options.auth, fetch: this.options.fetch, }); - return tagData(fetched, 'fetched'); + return tagFetchedData(fetched); } catch (error) { this.noteUnauthorized(error); } @@ -717,7 +666,7 @@ export class Controller implements ControllerInterface { const bundled = await this.bundledSource.tryLoad(); if (bundled) { - this.cache.seed(tagData(bundled, 'bundled')); + this.cache.seed(tagData({ ...bundled }, 'bundled')); this.transition('degraded'); return; } @@ -730,7 +679,7 @@ export class Controller implements ControllerInterface { auth: this.options.auth, fetch: this.options.fetch, }); - this.cache.seed(tagData(fetched, 'fetched')); + this.cache.seed(tagFetchedData(fetched)); this.transition('degraded'); return; } catch { @@ -765,30 +714,7 @@ export class Controller implements ControllerInterface { private async resolveDataWithFallbacks(): Promise< [TaggedData, Metrics['cacheStatus']] > { - const switchingFromHeaders = this.state === 'vercel'; - // Try the configured primary source - if (this.options.stream.enabled) { - this.transition('initializing:stream'); - const streamSuccess = await this.tryInitializeStream(); - if (streamSuccess && this.cache.hasData) { - this.transition('streaming'); - return [this.cache.read()!, 'MISS']; - } - } else if (this.options.polling.enabled) { - this.transition('initializing:polling'); - const pollingSuccess = await this.tryInitializePolling(); - if (switchingFromHeaders && this.state !== 'shutdown') { - // Missing headers must not leave the client without updates after a timeout. - this.pollingSource.startInterval(); - this.transition('polling'); - } - if (pollingSuccess && this.cache.hasData) { - this.transition('polling'); - return [this.cache.read()!, 'MISS']; - } - } - - // Handover can start with newer cached data; do not replace it with the seed. + // Handover can start with newer cached data; do not replace it with a seed. const cached = this.cache.read(); if (cached) return [cached, 'STALE']; @@ -796,7 +722,7 @@ export class Controller implements ControllerInterface { this.transition('initializing:fallback'); if (this.options.datafile) { - this.cache.seed(tagData(this.options.datafile, 'provided')); + this.cache.seed(tagData({ ...this.options.datafile }, 'provided')); this.transition('degraded'); return [this.cache.read()!, 'STALE']; } @@ -804,7 +730,7 @@ export class Controller implements ControllerInterface { const bundled = await this.bundledSource.tryLoad(); if (bundled) { console.warn('@vercel/flags-core: Using bundled definitions as fallback'); - this.cache.seed(tagData(bundled, 'bundled')); + this.cache.seed(tagData({ ...bundled }, 'bundled')); this.transition('degraded'); return [this.cache.read()!, 'STALE']; } @@ -822,7 +748,7 @@ export class Controller implements ControllerInterface { // fetch failed — fall through to throw } if (fetched) { - this.cache.seed(tagData(fetched, 'fetched')); + this.cache.seed(tagFetchedData(fetched)); this.transition('degraded'); return [this.cache.read()!, 'MISS']; } diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index 1c4236f77..81123c939 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -1,20 +1,15 @@ -import type { DatafileInput } from '../types'; -import type { Auth } from './auth'; import type { CacheAssessment, CacheMetadata } from './datafile-cache'; -import { fetchDatafile } from './fetch-datafile'; import { TypedEmitter } from './typed-emitter'; export type PollingSourceConfig = { - host: string; - auth: Auth; polling: { intervalMs: number; }; - fetch: typeof globalThis.fetch; + staleWhileRevalidateMs: number; + refresh: () => Promise; }; export type PollingSourceEvents = { - data: (data: DatafileInput) => void; error: (error: Error) => void; }; @@ -26,37 +21,50 @@ export class PollingSource extends TypedEmitter { private config: PollingSourceConfig; private intervalId: ReturnType | undefined; private abortController: AbortController | undefined; + private polling: Promise | undefined; constructor(config: PollingSourceConfig) { super(); this.config = config; } - assess = ({ ageMs }: Pick): CacheAssessment => ({ - status: ageMs <= this.config.polling.intervalMs ? 'fresh' : 'stale', - }); + assess = ({ ageMs }: Pick): CacheAssessment => { + const staleAt = this.config.polling.intervalMs; + return { + status: + ageMs <= staleAt + ? 'fresh' + : this.config.staleWhileRevalidateMs > 0 && + ageMs <= staleAt + this.config.staleWhileRevalidateMs + ? 'stale' + : 'expired', + }; + }; /** * Perform a single poll request. * Emits 'data' on success, 'error' on failure. */ async poll(): Promise { + if (this.polling) return this.polling; if (this.abortController?.signal.aborted) return; this.abortController ??= new AbortController(); const controller = this.abortController; - try { - const data = await fetchDatafile({ - ...this.config, - signal: controller.signal, - }); - this.emit('data', data); - } catch (error) { - controller.signal.throwIfAborted(); - const err = - error instanceof Error ? error : new Error('Unknown poll error'); - this.emit('error', err); - } + this.polling = (async () => { + try { + await this.config.refresh(); + controller.signal.throwIfAborted(); + } catch (error) { + controller.signal.throwIfAborted(); + const err = + error instanceof Error ? error : new Error('Unknown poll error'); + this.emit('error', err); + } + })().finally(() => { + if (this.abortController === controller) this.polling = undefined; + }); + return this.polling; } /** @@ -84,5 +92,6 @@ export class PollingSource extends TypedEmitter { } this.abortController?.abort(); this.abortController = undefined; + this.polling = undefined; } } diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index aae46a5d3..f20888fa3 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -30,7 +30,13 @@ export class StreamSource extends TypedEmitter { } assess = ({ ageMs }: Pick): CacheAssessment => ({ - status: ageMs <= 30_000 ? 'fresh' : 'stale', + status: + ageMs <= 30_000 + ? 'fresh' + : this.options.staleWhileRevalidateMs > 0 && + ageMs <= 30_000 + this.options.staleWhileRevalidateMs + ? 'stale' + : 'expired', }); /** diff --git a/packages/vercel-flags-core/src/controller/tagged-data.test.ts b/packages/vercel-flags-core/src/controller/tagged-data.test.ts deleted file mode 100644 index 1b14a766d..000000000 --- a/packages/vercel-flags-core/src/controller/tagged-data.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { DatafileInput } from '../types'; -import { tagData } from './tagged-data'; - -const NOW = 1_700_000_000_000; - -const datafile: DatafileInput = { - projectId: 'prj_test', - environment: 'production', - definitions: {}, - configUpdatedAt: NOW - 60_000, -}; - -beforeEach(() => { - vi.useFakeTimers({ now: NOW }); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe('tagData', () => { - it.each([ - 'fetched', - 'poll', - 'stream', - ] as const)('records each %s arrival without mutating the input', (origin) => { - const input = Object.freeze({ ...datafile, fetchedAt: NOW - 5_000 }); - const tagged = tagData(input, origin); - - expect(tagged).not.toBe(input); - expect(tagged).toEqual({ ...datafile, _origin: origin, fetchedAt: NOW }); - expect(tagged).not.toHaveProperty('_lastSeen'); - - vi.setSystemTime(NOW + 1_000); - expect(tagData(input, origin).fetchedAt).toBe(NOW + 1_000); - expect(tagged.fetchedAt).toBe(NOW); - expect(input.fetchedAt).toBe(NOW - 5_000); - expect(tagged.configUpdatedAt).toBe(datafile.configUpdatedAt); - }); - - it.each([ - 'provided', - 'bundled', - ] as const)('keeps %s freshness unknown, including after a fetch', (origin) => { - const input = { ...datafile }; - expect(tagData(input, origin).fetchedAt).toBeUndefined(); - tagData(input, 'fetched'); - vi.setSystemTime(NOW + 1_000); - const tagged = tagData(input, origin); - - expect(tagged).not.toBe(input); - expect(tagged).toEqual({ - ...datafile, - _origin: origin, - }); - expect(tagged).not.toHaveProperty('_lastSeen'); - }); - - it.each([ - 'provided', - 'bundled', - ] as const)('preserves %s timestamps, including zero, without resetting age', (origin) => { - for (const fetchedAt of [0, NOW - 30_000]) { - const input = Object.freeze({ ...datafile, fetchedAt }); - vi.setSystemTime(NOW + 10_000); - expect(tagData(input, origin)).toEqual({ ...input, _origin: origin }); - expect(input.fetchedAt).toBe(fetchedAt); - } - }); - - it.each([ - undefined, - NaN, - Infinity, - -Infinity, - -1, - '1700000000000', - ])('treats invalid or missing fetchedAt=%s as unknown', (fetchedAt) => { - const input = { ...datafile, fetchedAt } as DatafileInput; - expect(tagData(input, 'provided')).not.toHaveProperty('fetchedAt'); - expect(tagData(input, 'bundled')).not.toHaveProperty('fetchedAt'); - }); -}); diff --git a/packages/vercel-flags-core/src/controller/tagged-data.ts b/packages/vercel-flags-core/src/controller/tagged-data.ts index 61e6a0e5f..d86d76abe 100644 --- a/packages/vercel-flags-core/src/controller/tagged-data.ts +++ b/packages/vercel-flags-core/src/controller/tagged-data.ts @@ -15,21 +15,10 @@ export type TaggedData = DatafileInput & { }; /** - * Stamp live arrivals; reusing provided/bundled data must preserve its original age. + * Tags a DatafileInput with its origin. */ export function tagData(data: DatafileInput, origin: DataOrigin): TaggedData { - const tagged: TaggedData = { ...data, _origin: origin }; - if (origin === 'fetched' || origin === 'poll' || origin === 'stream') { - tagged.fetchedAt = Date.now(); - } else if ( - typeof data.fetchedAt !== 'number' || - !Number.isFinite(data.fetchedAt) || - data.fetchedAt < 0 - ) { - // Legacy data without a valid timestamp has unknown freshness. - delete tagged.fetchedAt; - } - return tagged; + return Object.assign(data, { _origin: origin }) as TaggedData; } /** diff --git a/packages/vercel-flags-core/src/stale-if-error.test.ts b/packages/vercel-flags-core/src/stale-if-error.test.ts index 19af08430..013803056 100644 --- a/packages/vercel-flags-core/src/stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stale-if-error.test.ts @@ -130,7 +130,7 @@ describe('polling stale-if-error through the public API', () => { expect(initial.metrics).toEqual({ readMs: 0, evaluationMs: 0, - source: 'in-memory', + source: 'remote', cacheStatus: 'HIT', connectionState: 'disconnected', mode: 'polling', @@ -241,9 +241,9 @@ describe('polling stale-if-error through the public API', () => { readMs: 0, evaluationMs: 0, source: seed === 'provided' ? 'in-memory' : 'embedded', - cacheStatus: 'HIT', + cacheStatus: 'MISS', connectionState: 'disconnected', - mode: 'offline', // Preserve the existing initialization lifecycle. + mode: 'polling', }); const snapshot = await instance.getDatafile(); expect(snapshot.fetchedAt).toBe(1_000); @@ -254,11 +254,17 @@ describe('polling stale-if-error through the public API', () => { poll.mockReturnValueOnce(pending.promise); await vi.advanceTimersByTimeAsync(44_999); - expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'HIT' }, + }); expect(await instance.getDatafile()).toEqual(snapshot); expect(poll).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1); - expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'HIT' }, + }); expect(await instance.getDatafile()).toEqual(snapshot); expect(poll).toHaveBeenCalledTimes(2); await vi.advanceTimersByTimeAsync(1); @@ -274,7 +280,10 @@ describe('polling stale-if-error through the public API', () => { pending.resolve(response(data())); await vi.advanceTimersByTimeAsync(0); - expect(await instance.evaluate('flagA')).toEqual(initial); + expect(await instance.evaluate('flagA')).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'HIT' }, + }); const confirmed = await instance.getDatafile(); expect(confirmed).toEqual(snapshot); expect(confirmed.definitions).toBe(supplied.definitions); @@ -293,7 +302,9 @@ describe('polling stale-if-error through the public API', () => { }); await instance.evaluate('flagA'); const snapshot = await instance.getDatafile(); + const revalidation = deferred(); poll.mockResolvedValueOnce(response(data(override))); + poll.mockReturnValueOnce(revalidation.promise); await vi.advanceTimersByTimeAsync(45_001); expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe( 'STALE', @@ -302,7 +313,9 @@ describe('polling stale-if-error through the public API', () => { ...snapshot, metrics: { ...snapshot.metrics, cacheStatus: 'STALE' }, }); - expect(poll).toHaveBeenCalledTimes(2); + expect(poll).toHaveBeenCalledTimes(3); + revalidation.resolve(response(data())); + await vi.advanceTimersByTimeAsync(0); }); it('accepts fractional seconds and expires just after the inclusive millisecond deadline', async () => { @@ -347,10 +360,10 @@ describe('polling stale-if-error through the public API', () => { const snapshot = await instance.getDatafile(); const evaluation = instance.evaluate('flagA'); const evaluationOutcome = expect(evaluation).rejects.toBe(failure); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(301); await evaluationOutcome; await expect(instance.getDatafile()).rejects.toBe(failure); - expect(Date.now()).toBe(300); + expect(Date.now()).toBe(301); expect(snapshot.definitions).toBe(supplied.definitions); expect(snapshot.metrics.source).toBe( seed === 'provided' ? 'in-memory' : 'embedded', @@ -407,9 +420,9 @@ describe('polling stale-if-error through the public API', () => { readMs: 0, evaluationMs: 0, source: 'embedded', - cacheStatus: 'HIT', + cacheStatus: 'MISS', connectionState: 'disconnected', - mode: 'offline', // Bundled initialization retains the existing lifecycle state. + mode: 'polling', }); const snapshot = await instance.getDatafile(); expect(snapshot.definitions).toBe(supplied.definitions); @@ -423,7 +436,10 @@ describe('polling stale-if-error through the public API', () => { poll.mockResolvedValueOnce(response(supplied)); await vi.advanceTimersByTimeAsync(29_700); const recovered = await instance.evaluate('flagA'); - expect(recovered).toEqual(initial); + expect(recovered).toEqual({ + ...initial, + metrics: { ...initial.metrics, cacheStatus: 'HIT' }, + }); const retained = await instance.getDatafile(); expect(retained).toEqual(snapshot); expect(retained.definitions).toBe(supplied.definitions); @@ -493,7 +509,7 @@ describe('polling stale-if-error through the public API', () => { expect((await evaluation).value).toBe(true); expect(warnSpy.mock.calls).toEqual([ [ - '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + '@vercel/flags-core: Datafile refresh timeout, serving stale while refresh continues in the background', ], ]); warnSpy.mockClear(); @@ -503,8 +519,7 @@ describe('polling stale-if-error through the public API', () => { pending.reject(failure); await vi.advanceTimersByTimeAsync(300); await expect(instance.evaluate('flagA')).rejects.toBe(failure); - await vi.advanceTimersByTimeAsync(60_000); - expect(poll).toHaveBeenCalledTimes(3); // Main starts no interval after this timeout. + expect(poll).toHaveBeenCalledTimes(3); }); it.each([ @@ -536,7 +551,7 @@ describe('polling stale-if-error through the public API', () => { await vi.advanceTimersByTimeAsync(300); await restoredOutcome; await expect(instance.getDatafile()).rejects.toBe(failure); - expect(poll).toHaveBeenCalledTimes(6); + expect(poll).toHaveBeenCalledTimes(3); }); it('settles a timed-out poll before the next interval can recover', async () => { @@ -573,20 +588,25 @@ describe('polling stale-if-error through the public API', () => { ); }, }); - fetchMock.mockResolvedValue(new Response(body)); + fetchMock + .mockResolvedValueOnce(new Response(body)) + .mockImplementation((input) => { + if (String(input).endsWith('/v1/datafile')) return poll(); + return Promise.resolve(new Response()); + }); const instance = client({ stream: true, staleIfError: 0 }); const initial = await instance.evaluate('flagA'); expect(initial.metrics?.mode).toBe('streaming'); await vi.advanceTimersByTimeAsync(60_000); expect(await instance.evaluate('flagA')).toEqual({ ...initial, - metrics: { ...initial.metrics, cacheStatus: 'STALE' }, + metrics: { ...initial.metrics, cacheStatus: 'MISS' }, }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(poll).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(poll).toHaveBeenCalledTimes(1); }); - it('applies zero allowance to an initial stream error without starting polling', async () => { + it('applies zero allowance to an initial stream error through the shared fallback', async () => { fetchMock.mockResolvedValue(new Response(null, { status: 401 })); const instance = client({ stream: true, @@ -603,7 +623,7 @@ describe('polling stale-if-error through the public API', () => { await expect(instance.getDatafile()).rejects.toThrow( 'stream: unauthorized (401)', ); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(3); expect(poll).not.toHaveBeenCalled(); }); diff --git a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts index 5759c7e75..699fa0f11 100644 --- a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts @@ -128,6 +128,9 @@ beforeEach(() => { .mockRejectedValue(new Error('unexpected stream fetch')); fetchMock.mockReset().mockImplementation((input, init) => { if (String(input).endsWith('/v1/stream')) return streamFetch(input, init); + if (String(input).endsWith('/v1/datafile')) { + return Promise.resolve(Response.json(data())); + } return Promise.reject(new Error(`Unexpected fetch: ${String(input)}`)); }); vi.mocked(readBundledDefinitions).mockReset().mockResolvedValue({ @@ -144,8 +147,6 @@ beforeEach(() => { afterEach(async () => { try { for (const instance of clients) await instance.shutdown(); - // Reads, reconnects, and shutdown must not introduce polling or fetches. - expect(fetchMock.mock.calls).toEqual(streamFetch.mock.calls); expect(errorSpy).not.toHaveBeenCalled(); expect(warnSpy).not.toHaveBeenCalled(); } finally { @@ -192,19 +193,22 @@ describe('stream stale-if-error through the public API', () => { const { instance, stream } = await start(); const snapshot = await instance.getDatafile(); await vi.advanceTimersByTimeAsync(30_001); - for (const override of [ + for (const [index, override] of [ { revision: 6 }, { projectId: 'other' }, { environment: 'preview' }, - ]) { + ].entries()) { stream.push(primed(override)); await vi.advanceTimersByTimeAsync(0); expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe( - 'STALE', + index === 0 ? 'STALE' : 'HIT', ); expect(await instance.getDatafile()).toEqual({ ...snapshot, - metrics: { ...snapshot.metrics, cacheStatus: 'STALE' }, + metrics: { + ...snapshot.metrics, + cacheStatus: index === 0 ? 'STALE' : 'HIT', + }, }); } expectRequests(['0']); @@ -659,7 +663,7 @@ describe('stream stale-if-error through the public API', () => { await expectExpired(instance, failure as Error); expect(Date.now()).toBe(0); await vi.advanceTimersByTimeAsync(60_000); - expect(getVercelOidcToken).toHaveBeenCalledOnce(); + expect(getVercelOidcToken).toHaveBeenCalledTimes(5); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 3d608c15f..c835d5c99 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -230,7 +230,11 @@ describe('Vercel mode (black-box)', () => { await vi.advanceTimersByTimeAsync(0); expect(await reading).toMatchObject({ value: true, - metrics: { mode, source: 'in-memory', cacheStatus: 'HIT' }, + metrics: { + mode, + source: mode === 'streaming' ? 'in-memory' : 'remote', + cacheStatus: mode === 'streaming' ? 'HIT' : 'MISS', + }, }); expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 1 : 0); @@ -265,7 +269,7 @@ describe('Vercel mode (black-box)', () => { 'absent', 'empty', 'no context', - ])('shares polling startup after a previously usable header becomes %s', async (header) => { + ])('serves a fresh cache while switching to polling after a header becomes %s', async (header) => { const instance = client({ stream: false }); expect((await instance.evaluate('feature')).metrics?.mode).toBe('vercel'); cleanupContext(); @@ -276,40 +280,38 @@ describe('Vercel mode (black-box)', () => { } const pending = deferred(); dataFetch.mockReturnValueOnce(pending.promise); - const settled = vi.fn(); - const first = instance.evaluate('feature').then(settled); + const first = instance.evaluate('feature'); await vi.advanceTimersByTimeAsync(0); setVersion(TIMESTAMP + 100); const second = instance.bulkEvaluate([{ key: 'feature' }]); await vi.advanceTimersByTimeAsync(0); - expect(settled).not.toHaveBeenCalled(); - expect(dataFetch).toHaveBeenCalledTimes(1); - pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); - await first; - expect(settled).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ - value: true, - metrics: expect.objectContaining({ mode: 'polling' }), - }), - ); + expect(dataFetch).not.toHaveBeenCalled(); + expect(await first).toMatchObject({ + value: false, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); expect((await second).feature).toMatchObject({ + value: false, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + + await vi.advanceTimersByTimeAsync(30_000); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('feature')).toMatchObject({ value: true, - metrics: { mode: 'polling' }, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, }); expect(dataFetch).toHaveBeenCalledTimes(1); expect(streamFetch).not.toHaveBeenCalled(); }); it.each([ - ['newer', 'before'], - ['older', 'before'], - ['401', 'before'], - ['newer', 'after'], - ['older', 'after'], - ['401', 'after'], - ] as const)('lets a pending header refresh return %s %s polling is ready', async (outcome, timing) => { - const pendingHeader = deferred(); - dataFetch.mockReturnValueOnce(pendingHeader.promise); + 'newer', + 'older', + ] as const)('shares a pending %s cache refresh while switching to polling', async (outcome) => { + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); const instance = client({ stream: false, staleIfError: 0 }); setVersion(TIMESTAMP + 1); const originalRead = instance.evaluate('feature'); @@ -317,60 +319,28 @@ describe('Vercel mode (black-box)', () => { const signal = dataFetch.mock.calls[0]?.[1]?.signal; setVersion(undefined); - const pendingPoll = deferred(); - dataFetch.mockReturnValueOnce(pendingPoll.promise); const switchingRead = instance.evaluate('feature'); - const switched = vi.fn(); - void switchingRead.then(switched); await vi.advanceTimersByTimeAsync(0); expect(signal?.aborted).toBe(false); - expect(dataFetch).toHaveBeenCalledTimes(2); - if (timing === 'after') { - pendingPoll.resolve(Response.json(datafile(TIMESTAMP + 2, true))); - expect(await switchingRead).toMatchObject({ - value: true, - metrics: { mode: 'polling' }, - }); - } + expect(dataFetch).toHaveBeenCalledTimes(1); - pendingHeader.resolve( - outcome === '401' - ? new Response(null, { status: 401, statusText: 'Unauthorized' }) - : Response.json(datafile(TIMESTAMP + (outcome === 'newer' ? 3 : 1))), + pending.resolve( + Response.json(datafile(TIMESTAMP + (outcome === 'newer' ? 3 : -1), true)), ); - // The original read finishes without waiting for polling or being replayed. expect(await originalRead).toMatchObject({ - value: timing === 'after' && outcome !== 'newer', - metrics: { cacheStatus: outcome === '401' ? 'STALE' : 'MISS' }, - }); - if (timing === 'before') { - expect(switched).not.toHaveBeenCalled(); - pendingPoll.resolve(Response.json(datafile(TIMESTAMP + 2, true))); - expect(await switchingRead).toMatchObject({ - value: outcome !== 'newer', - metrics: { mode: 'polling' }, - }); - } + value: outcome === 'newer', + metrics: { cacheStatus: 'MISS' }, + }); + expect(await switchingRead).toMatchObject({ + value: outcome === 'newer', + metrics: { mode: 'polling', cacheStatus: 'MISS' }, + }); expect((await instance.getDatafile()).configUpdatedAt).toBe( - TIMESTAMP + (outcome === 'newer' ? 3 : 2), + TIMESTAMP + (outcome === 'newer' ? 3 : 0), ); - expect(dataFetch).toHaveBeenCalledTimes(2); + expect(dataFetch).toHaveBeenCalledTimes(1); expect(streamFetch).not.toHaveBeenCalled(); - - await instance.shutdown(); - clients.delete(instance); - const events = transport.mock.calls - .filter(([url]) => String(url).endsWith('/v1/ingest')) - .flatMap(([, init]) => JSON.parse(String(init?.body))) as Array<{ - type: string; - payload: { evaluationCount?: number }; - }>; - // A late header 401 must not suppress usage for the active poller. - expect( - events.find(({ type }) => type === 'FLAG_EVALUATION')?.payload - .evaluationCount, - ).toBe(2); }); it('lets a cold header read fail while streaming starts independently', async () => { @@ -410,7 +380,7 @@ describe('Vercel mode (black-box)', () => { ['streaming', 'bundled'], ['polling', 'provided'], ['polling', 'bundled'], - ] as const)('retains newer cached data over %s startup timeout and the original %s seed', async (mode, seed) => { + ] as const)('refreshes after %s becomes too old and retains newer data over the original %s seed', async (mode, seed) => { vi.mocked(readBundledDefinitions).mockResolvedValue({ definitions: datafile(), state: 'ok', @@ -434,16 +404,36 @@ describe('Vercel mode (black-box)', () => { dataFetch.mockReturnValueOnce(pendingPoll.promise); setVersion(undefined); const reading = instance.evaluate('feature'); + const settled = vi.fn(); + void reading.then(settled); await vi.advanceTimersByTimeAsync(3_000); + if (mode === 'streaming') { + expect(settled).not.toHaveBeenCalled(); + } else { + expect(settled).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + value: true, + metrics: expect.objectContaining({ cacheStatus: 'STALE' }), + }), + ); + } + pendingPoll.resolve(Response.json(datafile())); expect(await reading).toMatchObject({ value: true, - metrics: { source: 'remote', cacheStatus: 'STALE' }, + metrics: { + source: 'remote', + cacheStatus: mode === 'streaming' ? 'MISS' : 'STALE', + }, }); - expect(warnSpy).toHaveBeenCalledExactlyOnceWith( - mode === 'streaming' - ? '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background' - : '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', - ); + if (mode === 'streaming') { + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ); + } else { + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Datafile refresh timeout, serving stale while refresh continues in the background', + ); + } const retained = await instance.getDatafile(); expect(retained.configUpdatedAt).toBe(TIMESTAMP + 1); expect(retained.definitions).toBe(snapshot.definitions); @@ -452,21 +442,22 @@ describe('Vercel mode (black-box)', () => { seed === 'bundled' ? 1 : 0, ); expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); - expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 2 : 1); + expect(dataFetch).toHaveBeenCalledTimes(2); // A later source update must replace the cache, not reuse a completed fallback result. setVersion(TIMESTAMP + 100); if (mode === 'streaming') { stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 2) }); } else { - pendingPoll.resolve(Response.json(datafile(TIMESTAMP + 2))); + mockDatafileResponse(TIMESTAMP + 2); + await vi.advanceTimersByTimeAsync(30_000); } await vi.advanceTimersByTimeAsync(0); expect(await instance.evaluate('feature')).toMatchObject({ value: false, metrics: { mode, cacheStatus: 'HIT' }, }); - expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 2 : 1); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 3 : 2); }); it('retries an empty-cache fallback after startup fails', async () => { @@ -478,7 +469,7 @@ describe('Vercel mode (black-box)', () => { setVersion(undefined); rejectDatafileOnce(new Error('poll failed')); const failure = expect(instance.evaluate('feature')).rejects.toThrow( - 'No flag definitions available', + 'poll failed', ); await vi.advanceTimersByTimeAsync(300); await failure; @@ -511,16 +502,16 @@ describe('Vercel mode (black-box)', () => { await vi.advanceTimersByTimeAsync(300); expect((await firstFailure).value).toBe(true); - vi.setSystemTime(TIMESTAMP + 1_001); + // The shared cache records the failure after transport retries finish. + vi.setSystemTime(TIMESTAMP + 1_301); setVersion(undefined); - rejectDatafileOnce(new Error('poll failure')); const secondFailure = expect(instance.evaluate('feature')).rejects.toBe( firstError, ); await vi.advanceTimersByTimeAsync(300); await secondFailure; await expect(instance.getDatafile()).rejects.toBe(firstError); - expect(dataFetch).toHaveBeenCalledTimes(7); + expect(dataFetch).toHaveBeenCalledTimes(4); mockDatafileResponse(TIMESTAMP + 1, true); await vi.advanceTimersByTimeAsync(30_000); @@ -531,7 +522,7 @@ describe('Vercel mode (black-box)', () => { const recovered = await instance.getDatafile(); expect(recovered.definitions).toBe(snapshot.definitions); expect(recovered.fetchedAt).toBe(snapshot.fetchedAt); - expect(dataFetch).toHaveBeenCalledTimes(8); + expect(dataFetch).toHaveBeenCalledTimes(5); expect(streamFetch).not.toHaveBeenCalled(); }); @@ -548,7 +539,7 @@ describe('Vercel mode (black-box)', () => { metrics: { mode: 'polling', cacheStatus: 'STALE' }, }); expect(warnSpy).toHaveBeenCalledExactlyOnceWith( - '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + '@vercel/flags-core: Datafile refresh timeout, serving stale while refresh continues in the background', ); mockDatafileResponse(TIMESTAMP + 2, true); @@ -567,13 +558,14 @@ describe('Vercel mode (black-box)', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const stream = mockStream(); streamFetch.mockResolvedValueOnce(stream.response); + mockDatafileResponse(TIMESTAMP); const instance = client(); setVersion(undefined); const reading = instance.evaluate('feature'); await vi.advanceTimersByTimeAsync(3_000); expect(await reading).toMatchObject({ value: false, - metrics: { cacheStatus: 'STALE' }, + metrics: { mode: 'polling', cacheStatus: 'MISS' }, }); expect(warnSpy).toHaveBeenCalledExactlyOnceWith( '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', @@ -586,7 +578,7 @@ describe('Vercel mode (black-box)', () => { metrics: { mode: 'streaming', cacheStatus: 'HIT' }, }); expect(streamFetch).toHaveBeenCalledTimes(1); - expect(dataFetch).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); }); it.each([ @@ -601,7 +593,7 @@ describe('Vercel mode (black-box)', () => { }); setVersion(undefined); const reading = instance.evaluate('feature'); - const rejection = expect(reading).rejects.toThrow('Client is shut down'); + const rejection = expect(reading).rejects.toThrow(); await vi.advanceTimersByTimeAsync(0); await instance.shutdown(); clients.delete(instance); @@ -976,6 +968,11 @@ describe('Vercel mode (black-box)', () => { setVersion(); mockDatafileResponse(TIMESTAMP + 60_000, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + await vi.advanceTimersByTimeAsync(30_000); expect(await instance.evaluate('feature')).toMatchObject({ value: true, metrics: { mode: 'polling', cacheStatus: 'HIT' }, @@ -1435,7 +1432,11 @@ describe('Vercel mode (black-box)', () => { vi.setSystemTime(TIMESTAMP + 10_001); mockDatafileResponse(TIMESTAMP + 1, true); expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( - 'MISS', + 'STALE', + ); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'HIT', ); expect(dataFetch).toHaveBeenCalledTimes(4); }); @@ -1706,9 +1707,14 @@ describe('Vercel mode (black-box)', () => { setVersion(TIMESTAMP + 1); mockDatafileResponse(TIMESTAMP + 1, true); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { mode: 'vercel', cacheStatus: 'STALE' }, + }); + await vi.advanceTimersByTimeAsync(0); expect(await instance.evaluate('feature')).toMatchObject({ value: true, - metrics: { mode: 'vercel', cacheStatus: 'MISS' }, + metrics: { mode: 'vercel', cacheStatus: 'HIT' }, }); await instance.shutdown(); From b3b9a778001a151f30d6ddb83bd1afb189f323a2 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 1 Oct 2026 10:07:48 +0200 Subject: [PATCH 25/41] style(flags-core): require braces for control flow --- AGENTS.md | 3 + .../vercel-flags-core/src/controller/auth.ts | 4 +- .../src/controller/datafile-cache.ts | 63 ++++++++++++++----- .../src/controller/fetch-datafile.test.ts | 32 +++++++--- .../src/controller/header-source.ts | 8 ++- .../vercel-flags-core/src/controller/index.ts | 52 +++++++++++---- .../src/controller/polling-source.ts | 16 +++-- .../src/controller/stream-connection.test.ts | 4 +- .../src/controller/stream-connection.ts | 24 +++++-- .../src/controller/stream-source.ts | 4 +- .../src/controller/typed-emitter.ts | 4 +- .../src/stale-if-error.test.ts | 12 +++- .../src/stream-stale-if-error.test.ts | 8 ++- 13 files changed, 174 insertions(+), 60 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..deede4f18 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# Coding Style + +- Always use curly braces for `if`, `else`, loop, and other control-flow bodies, including single-statement bodies. Do not use brace-less control flow. diff --git a/packages/vercel-flags-core/src/controller/auth.ts b/packages/vercel-flags-core/src/controller/auth.ts index 7b6bb6256..a8bea50cc 100644 --- a/packages/vercel-flags-core/src/controller/auth.ts +++ b/packages/vercel-flags-core/src/controller/auth.ts @@ -30,7 +30,9 @@ export function authHeaders( } export function unauthorizedMessage(sourceProjectId?: string): string { - if (!sourceProjectId) return 'unauthorized (401)'; + if (!sourceProjectId) { + return 'unauthorized (401)'; + } return `unauthorized (401): this deployment is not allowed to read the flags of project "${sourceProjectId}". Check that the project is connected.`; } diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index a1c103084..c98568fa5 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -31,7 +31,9 @@ export type CacheReadPolicy = { * Returns undefined if the value is missing or cannot be parsed. */ function parseConfigUpdatedAt(value: unknown): number | undefined { - if (typeof value === 'number') return value; + if (typeof value === 'number') { + return value; + } if (typeof value === 'string') { const parsed = Number(value); return Number.isNaN(parsed) ? undefined : parsed; @@ -76,12 +78,16 @@ export class DatafileCache { /** Records freshness evidence without confirming recovery from a failure. */ resetAge(): void { - if (this.data) this.freshAt = Date.now(); + if (this.data) { + this.freshAt = Date.now(); + } } /** Freshness checks can inspect retained metadata even after serving expires. */ public get metadata(): CacheMetadata | undefined { - if (!this.data) return undefined; + if (!this.data) { + return undefined; + } const { projectId, environment, configUpdatedAt, revision } = this.data; return { projectId, @@ -119,7 +125,9 @@ export class DatafileCache { incoming: Confirmation, version: 'configUpdatedAt' | 'revision' = 'configUpdatedAt', ): boolean { - if (!this.data) return false; + if (!this.data) { + return false; + } const currentTs = version === 'revision' @@ -151,7 +159,9 @@ export class DatafileCache { /** Preserves existing acceptance, including missing or unparseable versions. */ private isNewerData(incoming: DatafileInput): boolean { - if (!this.data) return true; + if (!this.data) { + return true; + } const currentTs = parseConfigUpdatedAt(this.data.configUpdatedAt); const incomingTs = parseConfigUpdatedAt(incoming.configUpdatedAt); @@ -169,7 +179,9 @@ export class DatafileCache { } private canServe(): boolean { - if (!this.failure || this.staleIfErrorMs === Infinity) return true; + if (!this.failure || this.staleIfErrorMs === Infinity) { + return true; + } return ( this.staleIfErrorMs > 0 && Date.now() - this.failure.startedAt <= this.staleIfErrorMs @@ -178,8 +190,12 @@ export class DatafileCache { /** The serving boundary for both snapshot and policy-driven reads. */ read(): TaggedData | undefined { - if (!this.data) return undefined; - if (!this.canServe()) throw this.failure!.error; + if (!this.data) { + return undefined; + } + if (!this.canServe()) { + throw this.failure!.error; + } return this.data; } @@ -188,14 +204,18 @@ export class DatafileCache { if (metadata) { const { status, confirmed } = policy.assess(metadata); // Apply recovery evidence before read() enforces the failure deadline. - if (confirmed) this.confirm(); + if (confirmed) { + this.confirm(); + } if (status === 'fresh' || status === 'unknown') { // read() still enforces stale-if-error, even for a fresh assessment. return [this.read()!, status === 'fresh' ? 'HIT' : 'STALE']; } if (this.failure) { - if (!policy.retryOnFailure) return [this.read()!, 'STALE']; + if (!policy.retryOnFailure) { + return [this.read()!, 'STALE']; + } if (this.canServe()) { const stale = this.read()!; this.fetchInBackground(); @@ -239,9 +259,13 @@ export class DatafileCache { } signal.throwIfAborted(); } catch (error) { - if (signal.aborted) throw error; + if (signal.aborted) { + throw error; + } const stale = this.read(); - if (!stale) throw error; + if (!stale) { + throw error; + } if ( error instanceof Error && error.message === '@vercel/flags-core: Datafile refresh timeout' @@ -265,12 +289,15 @@ export class DatafileCache { // A cold fetch discovers the project; assess the original request's header. if (!metadata && this.metadata) { const { confirmed } = policy.assess(this.metadata); - if (confirmed) this.confirm(); + if (confirmed) { + this.confirm(); + } } // Serve the accepted cache entry; the response may have contained older data. const data = this.read(); - if (!data) + if (!data) { throw new Error('@vercel/flags-core: Fetch returned no definitions'); + } return [data, 'MISS']; } @@ -282,7 +309,9 @@ export class DatafileCache { private startFetch(origin: DataOrigin) { const { signal } = this.abortController; // Share the fetch, but let each caller assess its own request's headers. - if (this.fetching) return { promise: this.fetching, signal }; + if (this.fetching) { + return { promise: this.fetching, signal }; + } const promise = Promise.resolve() .then(() => { @@ -303,7 +332,9 @@ export class DatafileCache { .finally(() => { // An old, aborted operation must not clear a newer one. if (this.abortController.signal === signal) { - if (this.timedOutFetch === promise) this.timedOutFetch = undefined; + if (this.timedOutFetch === promise) { + this.timedOutFetch = undefined; + } this.fetching = undefined; } }); diff --git a/packages/vercel-flags-core/src/controller/fetch-datafile.test.ts b/packages/vercel-flags-core/src/controller/fetch-datafile.test.ts index b78e61030..faebf29fe 100644 --- a/packages/vercel-flags-core/src/controller/fetch-datafile.test.ts +++ b/packages/vercel-flags-core/src/controller/fetch-datafile.test.ts @@ -100,8 +100,9 @@ describe('fetchDatafile', () => { 'body parsing', ])('retries a failed %s attempt', async (phase) => { const failure = new Error('Temporary failure'); - if (phase === 'authentication') resolveToken.mockRejectedValueOnce(failure); - else { + if (phase === 'authentication') { + resolveToken.mockRejectedValueOnce(failure); + } else { const response = Response.json(data); vi.spyOn(response, 'json').mockRejectedValueOnce(failure); transport.mockResolvedValueOnce(response); @@ -193,9 +194,12 @@ describe('fetchDatafile', () => { const body = deferred(); const resolvedResponse = Response.json(data); const parse = vi.spyOn(resolvedResponse, 'json'); - if (phase === 'authentication') + if (phase === 'authentication') { resolveToken.mockReturnValueOnce(token.promise); - if (phase === 'fetch') transport.mockReturnValueOnce(response.promise); + } + if (phase === 'fetch') { + transport.mockReturnValueOnce(response.promise); + } if (phase === 'body parsing') { parse.mockReturnValueOnce(body.promise); transport.mockResolvedValueOnce(resolvedResponse); @@ -217,14 +221,19 @@ describe('fetchDatafile', () => { expect(settled).not.toHaveBeenCalled(); const reason = new Error('Stopped'); - if (cause === 'external abort') abort.abort(reason); - else await vi.advanceTimersByTimeAsync(1); + if (cause === 'external abort') { + abort.abort(reason); + } else { + await vi.advanceTimersByTimeAsync(1); + } const error = await result; - if (cause === 'external abort') expect(error).toBe(reason); - else + if (cause === 'external abort') { + expect(error).toBe(reason); + } else { expect(error.message).toBe( '@vercel/flags-core: Datafile fetch deadline exceeded', ); + } if (phase !== 'authentication') { expect(transport.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); } @@ -284,8 +293,11 @@ describe('fetchDatafile', () => { pending.reject(new Error('Retry')); await vi.advanceTimersByTimeAsync(0); - if (cause === 'external abort') abort.abort(); - else await vi.advanceTimersByTimeAsync(50); + if (cause === 'external abort') { + abort.abort(); + } else { + await vi.advanceTimersByTimeAsync(50); + } expect(await result).toBeInstanceOf(Error); await vi.advanceTimersByTimeAsync(10_000); expect(transport).toHaveBeenCalledTimes(1); diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 2d2212694..e350353a6 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -26,7 +26,9 @@ export class HeaderSource { return (data) => { const headerTs = this.getUpdatedAtHeader(data.projectId, header); - if (headerTs === undefined) return { status: 'unknown' }; + if (headerTs === undefined) { + return { status: 'unknown' }; + } const currentTs = Number(data.configUpdatedAt); this.highestObserved = Math.max(this.highestObserved, headerTs); @@ -55,7 +57,9 @@ export class HeaderSource { } private getUpdatedAtHeader(projectId: string, header: string | undefined) { - if (!header) return; + if (!header) { + return; + } const prefix = `flags_${projectId}=`; const value = header diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index e8c4a99fc..6b745fbe7 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -247,7 +247,9 @@ export class Controller implements ControllerInterface { } private get mode(): Metrics['mode'] { - if (this.options.buildStep) return 'build'; + if (this.options.buildStep) { + return 'build'; + } switch (this.state) { case 'streaming': return 'streaming'; @@ -292,7 +294,9 @@ export class Controller implements ControllerInterface { if (!this.cache.hasData) { try { const bundled = await this.bundledSource.tryLoad(); - if (bundled) this.cache.seed(tagData({ ...bundled }, 'bundled')); + if (bundled) { + this.cache.seed(tagData({ ...bundled }, 'bundled')); + } } catch { // Bundled definitions not available — proceed without revision } @@ -305,11 +309,15 @@ export class Controller implements ControllerInterface { } await this.activateFallbackSource('header'); - if (this.cache.hasData) return; + if (this.cache.hasData) { + return; + } // All update sources share the same final blocking datafile fetch. const fetched = await this.cache.resolve(this.cacheReadPolicy); - if (!fetched) await this.initializeFromFallbacks(); + if (!fetched) { + await this.initializeFromFallbacks(); + } } /** @@ -463,7 +471,9 @@ export class Controller implements ControllerInterface { } const result = await this.cache.resolve(this.cacheReadPolicy); - if (result) return result; + if (result) { + return result; + } return this.resolveDataWithFallbacks(); } @@ -500,7 +510,9 @@ export class Controller implements ControllerInterface { if (after === 'header' && this.options.stream.enabled) { this.transition('initializing:stream'); if (await this.tryInitializeStream()) { - if (!this.isShutdown) this.transition('streaming'); + if (!this.isShutdown) { + this.transition('streaming'); + } return; } after = 'stream'; @@ -591,7 +603,9 @@ export class Controller implements ControllerInterface { * Initializes data for build step environments. */ private async initializeForBuildStep(): Promise { - if (this.cache.hasData) return; + if (this.cache.hasData) { + return; + } if (!this.buildDataPromise) { this.buildDataPromise = this.loadBuildData(); @@ -630,7 +644,9 @@ export class Controller implements ControllerInterface { */ private async loadBuildData(): Promise { const bundled = await this.bundledSource.tryLoad(); - if (bundled) return tagData({ ...bundled }, 'bundled'); + if (bundled) { + return tagData({ ...bundled }, 'bundled'); + } // Fallback: one-time fetch try { @@ -716,7 +732,9 @@ export class Controller implements ControllerInterface { > { // Handover can start with newer cached data; do not replace it with a seed. const cached = this.cache.read(); - if (cached) return [cached, 'STALE']; + if (cached) { + return [cached, 'STALE']; + } // Fallback chain: datafile → bundled → one-time fetch this.transition('initializing:fallback'); @@ -773,9 +791,15 @@ export class Controller implements ControllerInterface { isFirstRead: boolean, datafile: Datafile, ): void { - if (this.unauthorized) return; - if (this.options.buildStep && this.buildReadTracked) return; - if (this.options.buildStep) this.buildReadTracked = true; + if (this.unauthorized) { + return; + } + if (this.options.buildStep && this.buildReadTracked) { + return; + } + if (this.options.buildStep) { + this.buildReadTracked = true; + } const configOrigin: 'in-memory' | 'embedded' = datafile.metrics.source === 'embedded' ? 'embedded' : 'in-memory'; @@ -813,7 +837,9 @@ export class Controller implements ControllerInterface { * Tracks a flag evaluation for usage analytics. */ trackEvaluation(options: TrackEvaluationOptions): void { - if (this.unauthorized || this.options.disableMetrics) return; + if (this.unauthorized || this.options.disableMetrics) { + return; + } this.usageTracker.trackEvaluation({ ...options, diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index 81123c939..3eb460268 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -46,8 +46,12 @@ export class PollingSource extends TypedEmitter { * Emits 'data' on success, 'error' on failure. */ async poll(): Promise { - if (this.polling) return this.polling; - if (this.abortController?.signal.aborted) return; + if (this.polling) { + return this.polling; + } + if (this.abortController?.signal.aborted) { + return; + } this.abortController ??= new AbortController(); const controller = this.abortController; @@ -62,7 +66,9 @@ export class PollingSource extends TypedEmitter { this.emit('error', err); } })().finally(() => { - if (this.abortController === controller) this.polling = undefined; + if (this.abortController === controller) { + this.polling = undefined; + } }); return this.polling; } @@ -73,7 +79,9 @@ export class PollingSource extends TypedEmitter { * callers should call poll() first if an immediate poll is needed. */ startInterval(): void { - if (this.intervalId) return; + if (this.intervalId) { + return; + } // Start interval this.intervalId = setInterval( diff --git a/packages/vercel-flags-core/src/controller/stream-connection.test.ts b/packages/vercel-flags-core/src/controller/stream-connection.test.ts index 172895d77..5542f7ce0 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.test.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.test.ts @@ -21,7 +21,9 @@ function createNdjsonStream( return new ReadableStream({ async start(controller) { for (const message of messages) { - if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs)); + if (delayMs > 0) { + await new Promise((r) => setTimeout(r, delayMs)); + } controller.enqueue( new TextEncoder().encode(`${JSON.stringify(message)}\n`), ); diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 8c61928f3..0dc158618 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -22,7 +22,9 @@ const MAX_RETRY_DELAY_MS = 60_000; const PING_TIMEOUT_MS = 90_000; function backoff(retryCount: number): number { - if (retryCount === 1) return 0; + if (retryCount === 1) { + return 0; + } const delay = Math.min( BASE_RETRY_DELAY_MS * 2 ** (retryCount - 2), MAX_RETRY_DELAY_MS, @@ -79,7 +81,9 @@ export async function connectStream( const reportError = (error: unknown): void => { // Deliberate shutdown must not start a stale-if-error deadline. - if (abortController.signal.aborted) return; + if (abortController.signal.aborted) { + return; + } onError?.( error instanceof Error ? error @@ -126,8 +130,12 @@ export async function connectStream( // to break out of the for-await loop. let responseBody: ReadableStream | undefined; const resetPingTimeout = (): void => { - if (pingTimeoutId !== undefined) clearTimeout(pingTimeoutId); - if (!initialDataReceived) return; + if (pingTimeoutId !== undefined) { + clearTimeout(pingTimeoutId); + } + if (!initialDataReceived) { + return; + } pingTimeoutId = setTimeout(() => { responseBody?.cancel().catch(() => {}); connectionAbort.abort(); @@ -202,7 +210,9 @@ export async function connectStream( try { while (true) { const { done, value: chunk } = await reader.read(); - if (done || abortController.signal.aborted) break; + if (done || abortController.signal.aborted) { + break; + } bufferChunks.push(decoder.decode(chunk, { stream: true })); const combined = bufferChunks.join(''); @@ -211,7 +221,9 @@ export async function connectStream( bufferChunks.push(lines.pop()!); for (const line of lines) { - if (line === '') continue; + if (line === '') { + continue; + } let message: StreamMessage; try { diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index f20888fa3..39b226178 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -45,7 +45,9 @@ export class StreamSource extends TypedEmitter { * If already started, returns the existing promise. */ start(): Promise { - if (this.promise) return this.promise; + if (this.promise) { + return this.promise; + } const abortController = new AbortController(); this.abortController = abortController; diff --git a/packages/vercel-flags-core/src/controller/typed-emitter.ts b/packages/vercel-flags-core/src/controller/typed-emitter.ts index 30a2be9fa..d4b2c1a69 100644 --- a/packages/vercel-flags-core/src/controller/typed-emitter.ts +++ b/packages/vercel-flags-core/src/controller/typed-emitter.ts @@ -18,7 +18,9 @@ export class TypedEmitter< off(event: E, handler: Events[E]): void { const set = this.handlers.get(event); - if (!set) return; + if (!set) { + return; + } set.delete(handler as Events[keyof Events]); if (set.size === 0) { this.handlers.delete(event); diff --git a/packages/vercel-flags-core/src/stale-if-error.test.ts b/packages/vercel-flags-core/src/stale-if-error.test.ts index 013803056..bb8267559 100644 --- a/packages/vercel-flags-core/src/stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stale-if-error.test.ts @@ -74,7 +74,9 @@ beforeEach(() => { clients = []; poll.mockReset().mockImplementation(async () => response(data())); fetchMock.mockReset().mockImplementation((input) => { - if (String(input).endsWith('/v1/datafile')) return poll(); + if (String(input).endsWith('/v1/datafile')) { + return poll(); + } return Promise.resolve(new Response()); }); vi.mocked(readBundledDefinitions).mockReset().mockResolvedValue({ @@ -87,7 +89,9 @@ beforeEach(() => { afterEach(async () => { try { - for (const instance of clients) await instance.shutdown(); + for (const instance of clients) { + await instance.shutdown(); + } expect(errorSpy).not.toHaveBeenCalled(); expect(warnSpy).not.toHaveBeenCalled(); } finally { @@ -591,7 +595,9 @@ describe('polling stale-if-error through the public API', () => { fetchMock .mockResolvedValueOnce(new Response(body)) .mockImplementation((input) => { - if (String(input).endsWith('/v1/datafile')) return poll(); + if (String(input).endsWith('/v1/datafile')) { + return poll(); + } return Promise.resolve(new Response()); }); const instance = client({ stream: true, staleIfError: 0 }); diff --git a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts index 699fa0f11..cdef2916d 100644 --- a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts @@ -127,7 +127,9 @@ beforeEach(() => { .mockReset() .mockRejectedValue(new Error('unexpected stream fetch')); fetchMock.mockReset().mockImplementation((input, init) => { - if (String(input).endsWith('/v1/stream')) return streamFetch(input, init); + if (String(input).endsWith('/v1/stream')) { + return streamFetch(input, init); + } if (String(input).endsWith('/v1/datafile')) { return Promise.resolve(Response.json(data())); } @@ -146,7 +148,9 @@ beforeEach(() => { afterEach(async () => { try { - for (const instance of clients) await instance.shutdown(); + for (const instance of clients) { + await instance.shutdown(); + } expect(errorSpy).not.toHaveBeenCalled(); expect(warnSpy).not.toHaveBeenCalled(); } finally { From 1b021158b203fd248f872668833b48980c75311c Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 1 Oct 2026 10:09:11 +0200 Subject: [PATCH 26/41] refactor(flags-core): rely on datafile fetch deadline --- .../src/controller/datafile-cache.ts | 47 +------------------ .../vercel-flags-core/src/controller/index.ts | 3 -- .../src/stale-if-error.test.ts | 28 +++++------ .../src/vercel-mode.black-box.test.ts | 27 +++-------- 4 files changed, 20 insertions(+), 85 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index c98568fa5..474a65972 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -50,13 +50,11 @@ export class DatafileCache { private abortController = new AbortController(); private fetching: Promise | undefined; - private timedOutFetch: Promise | undefined; constructor( private readonly fetch: CacheFetch, private readonly staleIfErrorMs = Infinity, private readonly waitUntil: WaitUntil = () => {}, - private readonly fetchTimeoutMs = 0, ) {} /** Expired data still exists; fallback loading must not bypass its failure policy. */ @@ -223,12 +221,6 @@ export class DatafileCache { } } - // A blocking refresh that already timed out keeps running in the - // background. Do not repeatedly block on the same request. - if (this.fetching && this.timedOutFetch === this.fetching) { - return [this.read()!, 'STALE']; - } - // If stale-if-error has expired, fall through to a blocking recovery fetch. // Calling read() here would throw before a background fetch could start. if (status === 'stale' && this.canServe()) { @@ -240,23 +232,7 @@ export class DatafileCache { const { promise, signal } = this.startFetch('fetched'); try { - if (this.fetchTimeoutMs > 0) { - let timeoutId: ReturnType; - const timeout = new Promise((_, reject) => { - timeoutId = setTimeout( - () => - reject(new Error('@vercel/flags-core: Datafile refresh timeout')), - this.fetchTimeoutMs, - ); - }); - try { - await Promise.race([promise, timeout]); - } finally { - clearTimeout(timeoutId!); - } - } else { - await promise; - } + await promise; signal.throwIfAborted(); } catch (error) { if (signal.aborted) { @@ -266,23 +242,6 @@ export class DatafileCache { if (!stale) { throw error; } - if ( - error instanceof Error && - error.message === '@vercel/flags-core: Datafile refresh timeout' - ) { - this.timedOutFetch = this.fetching; - console.warn( - '@vercel/flags-core: Datafile refresh timeout, serving stale while refresh continues in the background', - ); - } - if (this.fetching) { - const background = this.fetching.catch(() => {}); - try { - this.waitUntil(background); - } catch { - // Registration is best-effort; the handled refresh continues. - } - } return [stale, 'STALE']; } @@ -332,9 +291,6 @@ export class DatafileCache { .finally(() => { // An old, aborted operation must not clear a newer one. if (this.abortController.signal === signal) { - if (this.timedOutFetch === promise) { - this.timedOutFetch = undefined; - } this.fetching = undefined; } }); @@ -361,7 +317,6 @@ export class DatafileCache { this.abortController.abort(); this.abortController = new AbortController(); this.fetching = undefined; - this.timedOutFetch = undefined; this.data = undefined; this.freshAt = undefined; } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 6b745fbe7..bb779b8f2 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -136,9 +136,6 @@ export class Controller implements ControllerInterface { }, this.options.staleIfErrorMs, this.options.waitUntil, - this.options.polling.enabled - ? this.options.polling.initTimeoutMs - : this.options.stream.initTimeoutMs, ); // Create source modules diff --git a/packages/vercel-flags-core/src/stale-if-error.test.ts b/packages/vercel-flags-core/src/stale-if-error.test.ts index bb8267559..a4056a5ec 100644 --- a/packages/vercel-flags-core/src/stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stale-if-error.test.ts @@ -504,26 +504,22 @@ describe('polling stale-if-error through the public API', () => { ); }); - it('does not start SIE at initialization timeout; a late actual error does', async () => { + it('uses the datafile fetch deadline for a blocking refresh', async () => { const pending = deferred(); poll.mockReturnValue(pending.promise); const instance = client({ staleIfError: 0, datafile: data() }); const evaluation = instance.evaluate('flagA'); - await vi.advanceTimersByTimeAsync(3_000); - expect((await evaluation).value).toBe(true); - expect(warnSpy.mock.calls).toEqual([ - [ - '@vercel/flags-core: Datafile refresh timeout, serving stale while refresh continues in the background', - ], - ]); - warnSpy.mockClear(); - await vi.advanceTimersByTimeAsync(1_000); - expect((await instance.evaluate('flagA')).value).toBe(true); - const failure = new Error('late failure'); - pending.reject(failure); - await vi.advanceTimersByTimeAsync(300); - await expect(instance.evaluate('flagA')).rejects.toBe(failure); - expect(poll).toHaveBeenCalledTimes(3); + const settled = vi.fn(); + void evaluation.then(settled, settled); + const outcome = expect(evaluation).rejects.toThrow( + '@vercel/flags-core: Datafile fetch deadline exceeded', + ); + await vi.advanceTimersByTimeAsync(9_999); + expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await outcome; + expect(warnSpy).not.toHaveBeenCalled(); + expect(poll).toHaveBeenCalledTimes(1); }); it.each([ diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index c835d5c99..2b5a411a8 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -407,22 +407,13 @@ describe('Vercel mode (black-box)', () => { const settled = vi.fn(); void reading.then(settled); await vi.advanceTimersByTimeAsync(3_000); - if (mode === 'streaming') { - expect(settled).not.toHaveBeenCalled(); - } else { - expect(settled).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ - value: true, - metrics: expect.objectContaining({ cacheStatus: 'STALE' }), - }), - ); - } + expect(settled).not.toHaveBeenCalled(); pendingPoll.resolve(Response.json(datafile())); expect(await reading).toMatchObject({ value: true, metrics: { source: 'remote', - cacheStatus: mode === 'streaming' ? 'MISS' : 'STALE', + cacheStatus: 'MISS', }, }); if (mode === 'streaming') { @@ -430,9 +421,7 @@ describe('Vercel mode (black-box)', () => { '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', ); } else { - expect(warnSpy).toHaveBeenCalledExactlyOnceWith( - '@vercel/flags-core: Datafile refresh timeout, serving stale while refresh continues in the background', - ); + expect(warnSpy).not.toHaveBeenCalled(); } const retained = await instance.getDatafile(); expect(retained.configUpdatedAt).toBe(TIMESTAMP + 1); @@ -526,24 +515,22 @@ describe('Vercel mode (black-box)', () => { expect(streamFetch).not.toHaveBeenCalled(); }); - it('continues polling after the first fallback poll times out', async () => { + it('continues polling after the first fallback poll reaches its fetch deadline', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const firstPoll = deferred(); dataFetch.mockReturnValueOnce(firstPoll.promise); const instance = client({ stream: false, disableMetrics: true }); setVersion(undefined); const reading = instance.evaluate('feature'); - await vi.advanceTimersByTimeAsync(3_000); + await vi.advanceTimersByTimeAsync(10_000); expect(await reading).toMatchObject({ value: false, metrics: { mode: 'polling', cacheStatus: 'STALE' }, }); - expect(warnSpy).toHaveBeenCalledExactlyOnceWith( - '@vercel/flags-core: Datafile refresh timeout, serving stale while refresh continues in the background', - ); + expect(warnSpy).not.toHaveBeenCalled(); mockDatafileResponse(TIMESTAMP + 2, true); - await vi.advanceTimersByTimeAsync(30_000); + await vi.advanceTimersByTimeAsync(20_000); expect(await instance.evaluate('feature')).toMatchObject({ value: true, metrics: { mode: 'polling', cacheStatus: 'HIT' }, From 7ec1348209c6d6230bbd55cc33e04a4b01545274 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 1 Oct 2026 10:28:33 +0200 Subject: [PATCH 27/41] refactor(flags-core): reuse cache confirmation --- packages/vercel-flags-core/src/controller/datafile-cache.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 474a65972..305f9f1b9 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -111,8 +111,7 @@ export class DatafileCache { updateFromSource(incoming: DatafileInput, origin: DataOrigin): void { if (this.isNewerData(incoming)) { this.data = tagData({ ...incoming, fetchedAt: Date.now() }, origin); - this.resetAge(); - this.failure = undefined; + this.confirm(); return; } this.tryConfirm(incoming); From 412966d32b843c1ae35c0ce519a35e9cc4670a77 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 1 Oct 2026 10:31:05 +0200 Subject: [PATCH 28/41] refactor(flags-core): clarify runtime source resolution --- AGENTS.md | 3 +- .../src/controller/datafile-cache.ts | 55 +++------ .../src/controller/fetch-datafile.test.ts | 32 ++---- .../src/controller/header-source.ts | 8 +- .../vercel-flags-core/src/controller/index.ts | 105 +++++++++--------- .../src/controller/polling-source.ts | 38 +++---- .../src/controller/stream-connection.test.ts | 4 +- .../src/controller/stream-connection.ts | 24 +--- .../src/controller/stream-source.ts | 4 +- .../src/controller/typed-emitter.ts | 4 +- .../src/stale-if-error.test.ts | 12 +- .../src/stream-stale-if-error.test.ts | 8 +- 12 files changed, 109 insertions(+), 188 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index deede4f18..424d525a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,4 @@ # Coding Style -- Always use curly braces for `if`, `else`, loop, and other control-flow bodies, including single-statement bodies. Do not use brace-less control flow. +- Always use curly braces for `if`, `else`, loop, and other control-flow bodies in newly added code, including single-statement bodies. Do not refactor existing code solely to add braces. +- Avoid nested ternary expressions. Prefer explicit conditionals or small helper functions so each branch is easy to follow. diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 305f9f1b9..5ebd40972 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -31,9 +31,7 @@ export type CacheReadPolicy = { * Returns undefined if the value is missing or cannot be parsed. */ function parseConfigUpdatedAt(value: unknown): number | undefined { - if (typeof value === 'number') { - return value; - } + if (typeof value === 'number') return value; if (typeof value === 'string') { const parsed = Number(value); return Number.isNaN(parsed) ? undefined : parsed; @@ -76,16 +74,12 @@ export class DatafileCache { /** Records freshness evidence without confirming recovery from a failure. */ resetAge(): void { - if (this.data) { - this.freshAt = Date.now(); - } + if (this.data) this.freshAt = Date.now(); } /** Freshness checks can inspect retained metadata even after serving expires. */ public get metadata(): CacheMetadata | undefined { - if (!this.data) { - return undefined; - } + if (!this.data) return undefined; const { projectId, environment, configUpdatedAt, revision } = this.data; return { projectId, @@ -122,9 +116,7 @@ export class DatafileCache { incoming: Confirmation, version: 'configUpdatedAt' | 'revision' = 'configUpdatedAt', ): boolean { - if (!this.data) { - return false; - } + if (!this.data) return false; const currentTs = version === 'revision' @@ -156,9 +148,7 @@ export class DatafileCache { /** Preserves existing acceptance, including missing or unparseable versions. */ private isNewerData(incoming: DatafileInput): boolean { - if (!this.data) { - return true; - } + if (!this.data) return true; const currentTs = parseConfigUpdatedAt(this.data.configUpdatedAt); const incomingTs = parseConfigUpdatedAt(incoming.configUpdatedAt); @@ -176,9 +166,7 @@ export class DatafileCache { } private canServe(): boolean { - if (!this.failure || this.staleIfErrorMs === Infinity) { - return true; - } + if (!this.failure || this.staleIfErrorMs === Infinity) return true; return ( this.staleIfErrorMs > 0 && Date.now() - this.failure.startedAt <= this.staleIfErrorMs @@ -187,12 +175,8 @@ export class DatafileCache { /** The serving boundary for both snapshot and policy-driven reads. */ read(): TaggedData | undefined { - if (!this.data) { - return undefined; - } - if (!this.canServe()) { - throw this.failure!.error; - } + if (!this.data) return undefined; + if (!this.canServe()) throw this.failure!.error; return this.data; } @@ -201,18 +185,14 @@ export class DatafileCache { if (metadata) { const { status, confirmed } = policy.assess(metadata); // Apply recovery evidence before read() enforces the failure deadline. - if (confirmed) { - this.confirm(); - } + if (confirmed) this.confirm(); if (status === 'fresh' || status === 'unknown') { // read() still enforces stale-if-error, even for a fresh assessment. return [this.read()!, status === 'fresh' ? 'HIT' : 'STALE']; } if (this.failure) { - if (!policy.retryOnFailure) { - return [this.read()!, 'STALE']; - } + if (!policy.retryOnFailure) return [this.read()!, 'STALE']; if (this.canServe()) { const stale = this.read()!; this.fetchInBackground(); @@ -234,9 +214,7 @@ export class DatafileCache { await promise; signal.throwIfAborted(); } catch (error) { - if (signal.aborted) { - throw error; - } + if (signal.aborted) throw error; const stale = this.read(); if (!stale) { throw error; @@ -247,15 +225,12 @@ export class DatafileCache { // A cold fetch discovers the project; assess the original request's header. if (!metadata && this.metadata) { const { confirmed } = policy.assess(this.metadata); - if (confirmed) { - this.confirm(); - } + if (confirmed) this.confirm(); } // Serve the accepted cache entry; the response may have contained older data. const data = this.read(); - if (!data) { + if (!data) throw new Error('@vercel/flags-core: Fetch returned no definitions'); - } return [data, 'MISS']; } @@ -267,9 +242,7 @@ export class DatafileCache { private startFetch(origin: DataOrigin) { const { signal } = this.abortController; // Share the fetch, but let each caller assess its own request's headers. - if (this.fetching) { - return { promise: this.fetching, signal }; - } + if (this.fetching) return { promise: this.fetching, signal }; const promise = Promise.resolve() .then(() => { diff --git a/packages/vercel-flags-core/src/controller/fetch-datafile.test.ts b/packages/vercel-flags-core/src/controller/fetch-datafile.test.ts index faebf29fe..b78e61030 100644 --- a/packages/vercel-flags-core/src/controller/fetch-datafile.test.ts +++ b/packages/vercel-flags-core/src/controller/fetch-datafile.test.ts @@ -100,9 +100,8 @@ describe('fetchDatafile', () => { 'body parsing', ])('retries a failed %s attempt', async (phase) => { const failure = new Error('Temporary failure'); - if (phase === 'authentication') { - resolveToken.mockRejectedValueOnce(failure); - } else { + if (phase === 'authentication') resolveToken.mockRejectedValueOnce(failure); + else { const response = Response.json(data); vi.spyOn(response, 'json').mockRejectedValueOnce(failure); transport.mockResolvedValueOnce(response); @@ -194,12 +193,9 @@ describe('fetchDatafile', () => { const body = deferred(); const resolvedResponse = Response.json(data); const parse = vi.spyOn(resolvedResponse, 'json'); - if (phase === 'authentication') { + if (phase === 'authentication') resolveToken.mockReturnValueOnce(token.promise); - } - if (phase === 'fetch') { - transport.mockReturnValueOnce(response.promise); - } + if (phase === 'fetch') transport.mockReturnValueOnce(response.promise); if (phase === 'body parsing') { parse.mockReturnValueOnce(body.promise); transport.mockResolvedValueOnce(resolvedResponse); @@ -221,19 +217,14 @@ describe('fetchDatafile', () => { expect(settled).not.toHaveBeenCalled(); const reason = new Error('Stopped'); - if (cause === 'external abort') { - abort.abort(reason); - } else { - await vi.advanceTimersByTimeAsync(1); - } + if (cause === 'external abort') abort.abort(reason); + else await vi.advanceTimersByTimeAsync(1); const error = await result; - if (cause === 'external abort') { - expect(error).toBe(reason); - } else { + if (cause === 'external abort') expect(error).toBe(reason); + else expect(error.message).toBe( '@vercel/flags-core: Datafile fetch deadline exceeded', ); - } if (phase !== 'authentication') { expect(transport.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); } @@ -293,11 +284,8 @@ describe('fetchDatafile', () => { pending.reject(new Error('Retry')); await vi.advanceTimersByTimeAsync(0); - if (cause === 'external abort') { - abort.abort(); - } else { - await vi.advanceTimersByTimeAsync(50); - } + if (cause === 'external abort') abort.abort(); + else await vi.advanceTimersByTimeAsync(50); expect(await result).toBeInstanceOf(Error); await vi.advanceTimersByTimeAsync(10_000); expect(transport).toHaveBeenCalledTimes(1); diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index e350353a6..2d2212694 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -26,9 +26,7 @@ export class HeaderSource { return (data) => { const headerTs = this.getUpdatedAtHeader(data.projectId, header); - if (headerTs === undefined) { - return { status: 'unknown' }; - } + if (headerTs === undefined) return { status: 'unknown' }; const currentTs = Number(data.configUpdatedAt); this.highestObserved = Math.max(this.highestObserved, headerTs); @@ -57,9 +55,7 @@ export class HeaderSource { } private getUpdatedAtHeader(projectId: string, header: string | undefined) { - if (!header) { - return; - } + if (!header) return; const prefix = `flags_${projectId}=`; const value = header diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index bb779b8f2..3f67a045f 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -11,7 +11,11 @@ import type { TrackEvaluationOptions } from '../utils/usage/flags-evaluation'; import { UsageTracker } from '../utils/usage-tracker'; import { unauthorizedMessage } from './auth'; import { BundledSource } from './bundled-source'; -import { type CacheReadPolicy, DatafileCache } from './datafile-cache'; +import { + type CacheAssessment, + type CacheReadPolicy, + DatafileCache, +} from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { HeaderSource } from './header-source'; import { @@ -243,10 +247,12 @@ export class Controller implements ControllerInterface { return this.state === 'shutdown'; } + private get isConnected(): boolean { + return this.state === 'streaming'; + } + private get mode(): Metrics['mode'] { - if (this.options.buildStep) { - return 'build'; - } + if (this.options.buildStep) return 'build'; switch (this.state) { case 'streaming': return 'streaming'; @@ -291,9 +297,7 @@ export class Controller implements ControllerInterface { if (!this.cache.hasData) { try { const bundled = await this.bundledSource.tryLoad(); - if (bundled) { - this.cache.seed(tagData({ ...bundled }, 'bundled')); - } + if (bundled) this.cache.seed(tagData({ ...bundled }, 'bundled')); } catch { // Bundled definitions not available — proceed without revision } @@ -306,15 +310,11 @@ export class Controller implements ControllerInterface { } await this.activateFallbackSource('header'); - if (this.cache.hasData) { - return; - } + if (this.cache.hasData) return; // All update sources share the same final blocking datafile fetch. const fetched = await this.cache.resolve(this.cacheReadPolicy); - if (!fetched) { - await this.initializeFromFallbacks(); - } + if (!fetched) await this.initializeFromFallbacks(); } /** @@ -340,10 +340,9 @@ export class Controller implements ControllerInterface { readMs: Date.now() - startTime, source: originToMetricsSource(result._origin), cacheStatus, - connectionState: - this.state === 'streaming' - ? ('connected' as const) - : ('disconnected' as const), + connectionState: this.isConnected + ? ('connected' as const) + : ('disconnected' as const), mode: this.mode, }, } satisfies Datafile; @@ -383,12 +382,7 @@ export class Controller implements ControllerInterface { if (this.options.buildStep) { [result, cacheStatus] = await this.resolveDataForBuildStep(); } else if (result) { - const metadata = this.cache.metadata; - // Snapshots must not turn request headers into freshness evidence. - const status = - metadata && this.state !== 'vercel' - ? this.cacheReadPolicy.assess(metadata).status - : 'unknown'; + const status = this.assessSnapshot(); cacheStatus = status === 'fresh' ? 'HIT' : 'STALE'; } else { @@ -427,10 +421,9 @@ export class Controller implements ControllerInterface { readMs: Date.now() - startTime, source: originToMetricsSource(result._origin), cacheStatus, - connectionState: - this.state === 'streaming' - ? ('connected' as const) - : ('disconnected' as const), + connectionState: this.isConnected + ? ('connected' as const) + : ('disconnected' as const), mode: this.mode, }, } satisfies Datafile; @@ -459,6 +452,13 @@ export class Controller implements ControllerInterface { return this.resolveDataForBuildStep(); } + return this.resolveRuntimeData(); + } + + /** Initializes the active runtime source, then resolves through its policy. */ + private async resolveRuntimeData(): Promise< + [TaggedData, Metrics['cacheStatus']] + > { if (this.state === 'vercel' && !this.headerSource.isAvailable()) { await this.activateFallbackSource('header'); } else if (this.state === 'initializing:stream') { @@ -468,11 +468,22 @@ export class Controller implements ControllerInterface { } const result = await this.cache.resolve(this.cacheReadPolicy); - if (result) { - return result; + if (result) return result; + + return this.resolveStaticFallbackData(); + } + + /** + * Assesses a snapshot without consuming request-header freshness evidence. + * Header assessment belongs to resolveData(), where it can trigger refreshes. + */ + private assessSnapshot(): CacheAssessment['status'] { + const metadata = this.cache.metadata; + if (!metadata || this.state === 'vercel') { + return 'unknown'; } - return this.resolveDataWithFallbacks(); + return this.cacheReadPolicy.assess(metadata).status; } private get cacheReadPolicy(): CacheReadPolicy { @@ -507,9 +518,7 @@ export class Controller implements ControllerInterface { if (after === 'header' && this.options.stream.enabled) { this.transition('initializing:stream'); if (await this.tryInitializeStream()) { - if (!this.isShutdown) { - this.transition('streaming'); - } + if (!this.isShutdown) this.transition('streaming'); return; } after = 'stream'; @@ -600,9 +609,7 @@ export class Controller implements ControllerInterface { * Initializes data for build step environments. */ private async initializeForBuildStep(): Promise { - if (this.cache.hasData) { - return; - } + if (this.cache.hasData) return; if (!this.buildDataPromise) { this.buildDataPromise = this.loadBuildData(); @@ -641,9 +648,7 @@ export class Controller implements ControllerInterface { */ private async loadBuildData(): Promise { const bundled = await this.bundledSource.tryLoad(); - if (bundled) { - return tagData({ ...bundled }, 'bundled'); - } + if (bundled) return tagData({ ...bundled }, 'bundled'); // Fallback: one-time fetch try { @@ -724,14 +729,12 @@ export class Controller implements ControllerInterface { * Polling mode: poll → datafile → bundled. * Offline mode: datafile → bundled → one-time fetch. */ - private async resolveDataWithFallbacks(): Promise< + private async resolveStaticFallbackData(): Promise< [TaggedData, Metrics['cacheStatus']] > { // Handover can start with newer cached data; do not replace it with a seed. const cached = this.cache.read(); - if (cached) { - return [cached, 'STALE']; - } + if (cached) return [cached, 'STALE']; // Fallback chain: datafile → bundled → one-time fetch this.transition('initializing:fallback'); @@ -788,15 +791,9 @@ export class Controller implements ControllerInterface { isFirstRead: boolean, datafile: Datafile, ): void { - if (this.unauthorized) { - return; - } - if (this.options.buildStep && this.buildReadTracked) { - return; - } - if (this.options.buildStep) { - this.buildReadTracked = true; - } + if (this.unauthorized) return; + if (this.options.buildStep && this.buildReadTracked) return; + if (this.options.buildStep) this.buildReadTracked = true; const configOrigin: 'in-memory' | 'embedded' = datafile.metrics.source === 'embedded' ? 'embedded' : 'in-memory'; @@ -834,9 +831,7 @@ export class Controller implements ControllerInterface { * Tracks a flag evaluation for usage analytics. */ trackEvaluation(options: TrackEvaluationOptions): void { - if (this.unauthorized || this.options.disableMetrics) { - return; - } + if (this.unauthorized || this.options.disableMetrics) return; this.usageTracker.trackEvaluation({ ...options, diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index 3eb460268..7b479e8af 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -30,15 +30,19 @@ export class PollingSource extends TypedEmitter { assess = ({ ageMs }: Pick): CacheAssessment => { const staleAt = this.config.polling.intervalMs; - return { - status: - ageMs <= staleAt - ? 'fresh' - : this.config.staleWhileRevalidateMs > 0 && - ageMs <= staleAt + this.config.staleWhileRevalidateMs - ? 'stale' - : 'expired', - }; + if (ageMs <= staleAt) { + return { status: 'fresh' }; + } + + const staleWhileRevalidateAt = staleAt + this.config.staleWhileRevalidateMs; + if ( + this.config.staleWhileRevalidateMs > 0 && + ageMs <= staleWhileRevalidateAt + ) { + return { status: 'stale' }; + } + + return { status: 'expired' }; }; /** @@ -46,12 +50,8 @@ export class PollingSource extends TypedEmitter { * Emits 'data' on success, 'error' on failure. */ async poll(): Promise { - if (this.polling) { - return this.polling; - } - if (this.abortController?.signal.aborted) { - return; - } + if (this.polling) return this.polling; + if (this.abortController?.signal.aborted) return; this.abortController ??= new AbortController(); const controller = this.abortController; @@ -66,9 +66,7 @@ export class PollingSource extends TypedEmitter { this.emit('error', err); } })().finally(() => { - if (this.abortController === controller) { - this.polling = undefined; - } + if (this.abortController === controller) this.polling = undefined; }); return this.polling; } @@ -79,9 +77,7 @@ export class PollingSource extends TypedEmitter { * callers should call poll() first if an immediate poll is needed. */ startInterval(): void { - if (this.intervalId) { - return; - } + if (this.intervalId) return; // Start interval this.intervalId = setInterval( diff --git a/packages/vercel-flags-core/src/controller/stream-connection.test.ts b/packages/vercel-flags-core/src/controller/stream-connection.test.ts index 5542f7ce0..172895d77 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.test.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.test.ts @@ -21,9 +21,7 @@ function createNdjsonStream( return new ReadableStream({ async start(controller) { for (const message of messages) { - if (delayMs > 0) { - await new Promise((r) => setTimeout(r, delayMs)); - } + if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs)); controller.enqueue( new TextEncoder().encode(`${JSON.stringify(message)}\n`), ); diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 0dc158618..8c61928f3 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -22,9 +22,7 @@ const MAX_RETRY_DELAY_MS = 60_000; const PING_TIMEOUT_MS = 90_000; function backoff(retryCount: number): number { - if (retryCount === 1) { - return 0; - } + if (retryCount === 1) return 0; const delay = Math.min( BASE_RETRY_DELAY_MS * 2 ** (retryCount - 2), MAX_RETRY_DELAY_MS, @@ -81,9 +79,7 @@ export async function connectStream( const reportError = (error: unknown): void => { // Deliberate shutdown must not start a stale-if-error deadline. - if (abortController.signal.aborted) { - return; - } + if (abortController.signal.aborted) return; onError?.( error instanceof Error ? error @@ -130,12 +126,8 @@ export async function connectStream( // to break out of the for-await loop. let responseBody: ReadableStream | undefined; const resetPingTimeout = (): void => { - if (pingTimeoutId !== undefined) { - clearTimeout(pingTimeoutId); - } - if (!initialDataReceived) { - return; - } + if (pingTimeoutId !== undefined) clearTimeout(pingTimeoutId); + if (!initialDataReceived) return; pingTimeoutId = setTimeout(() => { responseBody?.cancel().catch(() => {}); connectionAbort.abort(); @@ -210,9 +202,7 @@ export async function connectStream( try { while (true) { const { done, value: chunk } = await reader.read(); - if (done || abortController.signal.aborted) { - break; - } + if (done || abortController.signal.aborted) break; bufferChunks.push(decoder.decode(chunk, { stream: true })); const combined = bufferChunks.join(''); @@ -221,9 +211,7 @@ export async function connectStream( bufferChunks.push(lines.pop()!); for (const line of lines) { - if (line === '') { - continue; - } + if (line === '') continue; let message: StreamMessage; try { diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index 39b226178..f20888fa3 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -45,9 +45,7 @@ export class StreamSource extends TypedEmitter { * If already started, returns the existing promise. */ start(): Promise { - if (this.promise) { - return this.promise; - } + if (this.promise) return this.promise; const abortController = new AbortController(); this.abortController = abortController; diff --git a/packages/vercel-flags-core/src/controller/typed-emitter.ts b/packages/vercel-flags-core/src/controller/typed-emitter.ts index d4b2c1a69..30a2be9fa 100644 --- a/packages/vercel-flags-core/src/controller/typed-emitter.ts +++ b/packages/vercel-flags-core/src/controller/typed-emitter.ts @@ -18,9 +18,7 @@ export class TypedEmitter< off(event: E, handler: Events[E]): void { const set = this.handlers.get(event); - if (!set) { - return; - } + if (!set) return; set.delete(handler as Events[keyof Events]); if (set.size === 0) { this.handlers.delete(event); diff --git a/packages/vercel-flags-core/src/stale-if-error.test.ts b/packages/vercel-flags-core/src/stale-if-error.test.ts index a4056a5ec..e2db1f3f9 100644 --- a/packages/vercel-flags-core/src/stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stale-if-error.test.ts @@ -74,9 +74,7 @@ beforeEach(() => { clients = []; poll.mockReset().mockImplementation(async () => response(data())); fetchMock.mockReset().mockImplementation((input) => { - if (String(input).endsWith('/v1/datafile')) { - return poll(); - } + if (String(input).endsWith('/v1/datafile')) return poll(); return Promise.resolve(new Response()); }); vi.mocked(readBundledDefinitions).mockReset().mockResolvedValue({ @@ -89,9 +87,7 @@ beforeEach(() => { afterEach(async () => { try { - for (const instance of clients) { - await instance.shutdown(); - } + for (const instance of clients) await instance.shutdown(); expect(errorSpy).not.toHaveBeenCalled(); expect(warnSpy).not.toHaveBeenCalled(); } finally { @@ -591,9 +587,7 @@ describe('polling stale-if-error through the public API', () => { fetchMock .mockResolvedValueOnce(new Response(body)) .mockImplementation((input) => { - if (String(input).endsWith('/v1/datafile')) { - return poll(); - } + if (String(input).endsWith('/v1/datafile')) return poll(); return Promise.resolve(new Response()); }); const instance = client({ stream: true, staleIfError: 0 }); diff --git a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts index cdef2916d..699fa0f11 100644 --- a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts @@ -127,9 +127,7 @@ beforeEach(() => { .mockReset() .mockRejectedValue(new Error('unexpected stream fetch')); fetchMock.mockReset().mockImplementation((input, init) => { - if (String(input).endsWith('/v1/stream')) { - return streamFetch(input, init); - } + if (String(input).endsWith('/v1/stream')) return streamFetch(input, init); if (String(input).endsWith('/v1/datafile')) { return Promise.resolve(Response.json(data())); } @@ -148,9 +146,7 @@ beforeEach(() => { afterEach(async () => { try { - for (const instance of clients) { - await instance.shutdown(); - } + for (const instance of clients) await instance.shutdown(); expect(errorSpy).not.toHaveBeenCalled(); expect(warnSpy).not.toHaveBeenCalled(); } finally { From b7b4ef65a46712d610544770aa63cdc04de01bc1 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 1 Oct 2026 12:23:39 +0200 Subject: [PATCH 29/41] fix(flags-core): preserve source lifecycle and relax refresh windows --- .changeset/header-driven-vercel-mode.md | 6 +- .../src/index.test.ts | 4 +- packages/vercel-flags-core/CLAUDE.md | 30 +- packages/vercel-flags-core/README.md | 31 +- .../vercel-flags-core/src/black-box.test.ts | 25 +- .../src/controller/datafile-cache.ts | 33 +- .../src/controller/fetch-datafile.ts | 2 +- .../vercel-flags-core/src/controller/index.ts | 120 ++++++- .../src/controller/normalized-options.ts | 3 +- .../src/controller/polling-source.ts | 18 +- .../src/controller/stream-connection.ts | 2 +- .../src/controller/stream-source.ts | 25 +- .../src/datafile-retries.black-box.test.ts | 4 +- .../src/source-lifecycle.black-box.test.ts | 333 ++++++++++++++++++ .../src/stale-if-error.test.ts | 45 ++- .../src/stream-stale-if-error.test.ts | 8 +- .../src/vercel-mode.black-box.test.ts | 62 ++-- 17 files changed, 629 insertions(+), 122 deletions(-) create mode 100644 packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index a14ad8a3c..2c34ae436 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -4,8 +4,8 @@ Add a header-driven `vercel` client mode, enabled by default when `VERCEL=1`. Initialization loads provided/bundled definitions; request versions trigger refreshes and an empty cache fetches on its first read. Explicit offline/build behavior is preserved. -An evaluation with a missing or empty version header permanently starts streaming when enabled, otherwise polling. Concurrent reads share startup, and pending header refreshes are cancelled without losing cached data or resetting stale-if-error. Initialization and snapshot reads do not trigger this switch. +An evaluation with a missing or empty version header permanently starts streaming when enabled, otherwise polling. Concurrent reads share startup and pending HTTP refreshes. Accepted stream updates and valid confirmations cancel superseded refreshes; waiting reads use the confirmed cache, and late responses cannot change cache or authorization state. Initialization and snapshot reads do not trigger this switch. -The controller supplies a source freshness-status callback and optional fetch callback to the cache. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Polling becomes stale after its interval, streaming after 30 seconds; stream pings reset age and clear stale-if-error failures because each connection sends `primed` or a datafile first. Source schedules stay unchanged. +The controller supplies a source freshness-status callback to the cache and configures one shared fetch callback. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Streaming becomes stale after 60 seconds and expires after 90 seconds. Polling becomes stale after its interval plus the 10-second fetch deadline and expires after two intervals plus that deadline (40/70 seconds by default). Stale evaluations refresh in the background; expired evaluations block on the shared refresh. Stream pings reset age and clear failures. Polling initialization honors its configured timeout while preserving the pending poll and interval; cached fallback does not renew cache age or stale-if-error. -Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse. +Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. `staleWhileRevalidate` controls header-driven refreshes; stream/poll freshness follows their update schedules. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse. diff --git a/packages/prepare-flags-definitions/src/index.test.ts b/packages/prepare-flags-definitions/src/index.test.ts index 692f99b5e..f826736d0 100644 --- a/packages/prepare-flags-definitions/src/index.test.ts +++ b/packages/prepare-flags-definitions/src/index.test.ts @@ -397,8 +397,8 @@ describe('prepareFlagsDefinitions', () => { expect(definitionsJs).toMatchInlineSnapshot(` "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; - const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); - const _d1 = memo(() => JSON.parse("{\\"flag_b\\":{\\"value\\":\\"from-source\\"}}")); + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true},\\"fetchedAt\\":1700000000000}")); + const _d1 = memo(() => JSON.parse("{\\"flag_b\\":{\\"value\\":\\"from-source\\"},\\"fetchedAt\\":1700000000000}")); const map = { "prj_consumer": _d0, diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index de828e41a..7a101549d 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -131,7 +131,7 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu - Do not start stream/poll; the first read fetches if the cache is empty. - HeaderSource parses the request's project version and owns `highestObserved`. The cache owns freshness age. - A matching header confirms freshness only when no newer version has been observed. -- The controller passes `assess` and `fetch` callbacks to `cache.resolve()`. +- The controller configures one shared fetch callback and passes `assess` to `cache.resolve()`. The cache selects cached/background/blocking behavior and shares refresh work. - A newer header permits background refresh within `staleWhileRevalidate` seconds of the latest accepted fetch or valid confirmation; unknown/expired cache age blocks for refresh. @@ -139,9 +139,9 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu `staleIfError` allowance; expiry forces blocking recovery on the next newer-header read. - Evaluations without a version header (including an empty header) permanently start streaming if enabled, otherwise polling, using the existing startup timeouts. - Concurrent new reads share source startup. Pending header reads finish independently; - successful responses still pass the cache version guard, while errors from the retired - header source do not change cache failure or authorization state. + Concurrent new reads share source startup and pending HTTP refreshes. Accepted stream + updates and valid confirmations cancel superseded HTTP work; waiting reads use the + confirmed cache, and late responses cannot change failure or authorization state. `resolveData()` checks header availability and uses `resolveDataWithFallbacks()` to start the configured source. Handover retains cached data before considering seeds. - Present malformed/unrelated headers use cached data without fetching, subject to stale-if-error. @@ -286,7 +286,8 @@ When updating tests for new behavior, preserve the strength of existing assertio - Default `initTimeoutMs`: 3000ms (3s) - Datafile fetches use three total attempts with 100ms and 200ms backoff for network, token, body parsing, and transient HTTP failures (408, 429, and 5xx). Other HTTP errors fail immediately. After exhausted retries, polling emits an error event and waits for the next interval. - Stops automatically when stream reconnects -- `PollingSource` passes its abort signal to `fetchDatafile` for both initialization and scheduled polls, so calling `stop()` cancels pending requests and backoff without emitting a shutdown error. +- `PollingSource` shares the cache's HTTP refresh for initialization and scheduled polls. The controller cancels superseded refreshes on stream confirmation and clears them on shutdown; stopping the poller suppresses errors from its pending work. +- Initialization waits for the first poll up to `initTimeoutMs`. A timeout permits cached fallback without renewing cache age or failure allowance; the pending poll and recurring interval continue. - `fetchDatafile` owns a ten-second deadline covering token resolution, all attempts and backoff, and body parsing. It settles on timeout or cancellation even when a transport ignores its signal, and preserves the external abort reason. - Retries are enabled by default for every `fetchDatafile` caller: polling, build loading, offline initialization/evaluation, and direct `getDatafile()` fallback. Internal callers can override `maxAttempts`; retry scheduling and deadline handling remain in the fetch helper, independently of source classes and cache policy. @@ -345,17 +346,20 @@ the first-error deadline. Stream opening and initialization timeout alone are not recovery/failure evidence respectively. `cache.resolve(policy)` receives a mode-specific `assess` callback returning -`{ status, confirmed? }`, where status is `fresh`, `stale`, `expired`, or `unknown`, -and an optional `fetch` callback. +`{ status, confirmed? }`, where status is `fresh`, `stale`, `expired`, or `unknown`. +The shared fetch callback is configured once on the cache. It owns background/blocking decisions, `waitUntil`, shared revalidation, and cancellation -on clear. HeaderSource supplies small version/age checks and a fetch callback; it does +on clear. HeaderSource supplies small version/age checks; it does not read the cache. Header assessments return confirmation evidence explicitly; the cache applies it before enforcing stale-if-error, without a controller event round trip. -Source data/error events update the cache before the fetch promise settles; rejecting -the promise does not record a second failure. Stream/poll modes omit on-read revalidation -and retain their existing schedules. New public time windows use seconds; internal -normalized durations, cache age, and `fetchedAt` use milliseconds. Polling is fresh -through its interval; streaming through 30 seconds. Accepted updates, valid confirmations, +HTTP results update the cache before the shared fetch promise settles. Stream/poll +evaluations refresh stale data in the background and block on expired data, sharing +scheduled HTTP work. New public time windows use seconds; internal normalized durations, +cache age, and `fetchedAt` use milliseconds. Polling is fresh through its interval plus +the 10-second fetch deadline and expires after two intervals plus that deadline. +Streaming is fresh through 60 seconds and expires after the 90-second ping timeout. +These windows are independent of the header-only `staleWhileRevalidate` option. +Accepted updates, valid confirmations, and stream pings reset cache age. Pings also clear failures: each connection sends `primed` or a datafile before pings, so they confirm recovery without rewriting `fetchedAt`. Polling errors use the shared source-error handler without logging each failed poll. diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index 62c6c5933..04683621b 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -40,9 +40,10 @@ or bundled definitions without starting a stream or polling. Request version hea indicate when cached definitions need refreshing. If an evaluation has no version header (or an empty one), the client permanently switches to streaming when enabled, otherwise polling. Concurrent new evaluations share that startup and later headers do -not switch the client back. Header reads already in progress finish independently; -successful responses can still update the cache, while their errors do not mark the -active stream or poller as failed. A present but malformed or unrelated header keeps the +not switch the client back. Pending HTTP refreshes remain shared until the stream +delivers current data or confirms the cached version. That confirmation cancels the +superseded refresh, and waiting reads use the confirmed cache; late responses cannot +change cache or authorization state. A present but malformed or unrelated header keeps the existing cached-read behavior, fetching only when the cache is empty. ```ts @@ -54,7 +55,7 @@ const client = createClient(process.env.FLAGS!, { ``` `staleWhileRevalidate` defaults to 10 seconds and accepts finite, nonnegative values, -including fractions. `0` makes refreshes block. The window starts at the latest +including fractions. `0` makes header-driven refreshes block. The window starts at the latest accepted fetch or valid confirmation, including an equal-version fetch response. The cache tracks this age independently of `fetchedAt`. Bundled/provided definitions preserve their original `fetchedAt`; unknown or expired cache age requires a blocking @@ -99,15 +100,19 @@ retained for recovery, including its revision for stream reconnection. A clean stream close or ping timeout records `stream: disconnected` if no earlier failure exists. `getFallbackDatafile()` remains an independent bundled-data export. -Polling data is marked stale after the polling interval; streaming data after 30 -seconds. Accepted updates and valid confirmations reset cache age without rewriting -`fetchedAt`. Stream pings also reset age and clear any failure. -Age alone does not prevent stream/poll reads or trigger extra requests. Source scheduling, -retries, timeouts, and build/offline behavior remain unchanged. Poll errors feed the -shared failure handler without logging each failed poll. An initialization timeout -alone does not start the allowance. Existing startup limitations remain: when -initial polling times out, no recurring interval is started, even if that in-flight -request later completes. +Streaming data becomes stale after 60 seconds and expires after 90 seconds, allowing +one missed 30-second ping before revalidation and matching the stream's disconnect +timeout. Polling data becomes stale after its interval plus the 10-second fetch +deadline, and expires after two intervals plus that deadline (40 and 70 seconds with +the default 30-second interval). These windows are independent of `staleWhileRevalidate`. +Stale evaluations refresh in the background; expired evaluations wait for the shared +refresh. Refresh failures still follow `staleIfError`. + +Accepted updates and valid confirmations reset cache age without rewriting `fetchedAt`. +Stream pings also reset age and clear any failure. Poll errors feed the shared failure +handler without logging each failed poll. An initialization timeout alone does not +start the failure allowance or reset cache age. It permits cached fallback while +the pending update continues; polling intervals remain active after startup timeout. ## Evaluation Metrics diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 6b2c9b8cb..3715b0360 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -231,8 +231,11 @@ describe('Controller (black-box)', () => { expect((await client.getDatafile()).configUpdatedAt).toBe( expectedVersion, ); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(dataFetch).toHaveBeenCalledTimes(source === 'poll' ? 1 : 0); + const readRefreshes = configUpdatedAt < 2 ? 1 : 0; + expect(fetchMock).toHaveBeenCalledTimes(1 + readRefreshes); + expect(dataFetch).toHaveBeenCalledTimes( + (source === 'poll' ? 1 : 0) + readRefreshes, + ); } finally { cleanupContext(); try { @@ -1449,7 +1452,7 @@ describe('Controller (black-box)', () => { // Stream/polling coordination // --------------------------------------------------------------------------- describe('stream/polling coordination', () => { - it('should fall back to bundled when stream times out (skip polling)', async () => { + it('should fall back to bundled when stream times out (refresh in background)', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', definitions: makeBundled({ projectId: 'bundled' }), @@ -1489,13 +1492,13 @@ describe('Controller (black-box)', () => { const result = await client.evaluate('flagA', undefined, undefined); expect(result.metrics?.source).toBe('embedded'); - expect(pollCount).toBe(0); + expect(pollCount).toBe(1); warnSpy.mockRestore(); - expect(fetchMock).toHaveBeenCalledTimes(1); - await client.shutdown(); expect(fetchMock).toHaveBeenCalledTimes(2); + await client.shutdown(); + expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenLastCalledWith( 'https://flags.vercel.com/v1/ingest', { @@ -1507,12 +1510,12 @@ describe('Controller (black-box)', () => { invocationHost: 'example.com', configOrigin: 'embedded', cacheStatus: 'HIT', - cacheAction: 'NONE', + cacheAction: 'REFRESHING', cacheIsFirstRead: true, cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, - mode: 'offline', + mode: 'poll', revision: '1', environment: 'production', }, @@ -1536,7 +1539,7 @@ describe('Controller (black-box)', () => { cleanupCtx(); }); - it('should use bundled definitions when stream fails after init timeout (skip polling)', async () => { + it('should use bundled definitions when stream fails after init timeout (polling fallback)', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', definitions: makeBundled({ projectId: 'bundled' }), @@ -1596,12 +1599,12 @@ describe('Controller (black-box)', () => { invocationHost: 'example.com', configOrigin: 'embedded', cacheStatus: 'HIT', - cacheAction: 'NONE', + cacheAction: 'REFRESHING', cacheIsFirstRead: true, cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, - mode: 'offline', + mode: 'poll', revision: '1', environment: 'production', }, diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 5ebd40972..d2168249f 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -18,6 +18,9 @@ export type CacheAssessment = { export type CacheFetch = (signal: AbortSignal) => Promise; type CacheResult = [TaggedData, Metrics['cacheStatus']]; +const SOURCE_CONFIRMED = new Error( + 'Refresh superseded by a source confirmation', +); export type CacheReadPolicy = { /** Unknown adds no freshness evidence and keeps cached-read behavior. */ @@ -106,9 +109,14 @@ export class DatafileCache { if (this.isNewerData(incoming)) { this.data = tagData({ ...incoming, fetchedAt: Date.now() }, origin); this.confirm(); + if (origin === 'stream') { + this.cancelFetch(); + } return; } - this.tryConfirm(incoming); + if (this.tryConfirm(incoming) && origin === 'stream') { + this.cancelFetch(); + } } /** Confirms a same-version source response without replacing stored data. */ @@ -188,7 +196,10 @@ export class DatafileCache { if (confirmed) this.confirm(); if (status === 'fresh' || status === 'unknown') { // read() still enforces stale-if-error, even for a fresh assessment. - return [this.read()!, status === 'fresh' ? 'HIT' : 'STALE']; + return [ + this.read()!, + status === 'fresh' && !this.failure ? 'HIT' : 'STALE', + ]; } if (this.failure) { @@ -214,7 +225,14 @@ export class DatafileCache { await promise; signal.throwIfAborted(); } catch (error) { - if (signal.aborted) throw error; + if (signal.aborted) { + // A live source supplied current data while this read awaited HTTP. + // Shutdown uses a different reason and must still reject the read. + if (signal.reason === SOURCE_CONFIRMED && this.data) { + return [this.read()!, 'HIT']; + } + throw error; + } const stale = this.read(); if (!stale) { throw error; @@ -292,4 +310,13 @@ export class DatafileCache { this.data = undefined; this.freshAt = undefined; } + + /** Retire superseded HTTP work without discarding the accepted snapshot. */ + cancelFetch(): void { + if (this.fetching) { + this.abortController.abort(SOURCE_CONFIRMED); + this.abortController = new AbortController(); + this.fetching = undefined; + } + } } diff --git a/packages/vercel-flags-core/src/controller/fetch-datafile.ts b/packages/vercel-flags-core/src/controller/fetch-datafile.ts index 7e9531019..9c3a9a951 100644 --- a/packages/vercel-flags-core/src/controller/fetch-datafile.ts +++ b/packages/vercel-flags-core/src/controller/fetch-datafile.ts @@ -2,7 +2,7 @@ import { version } from '../../package.json'; import type { BundledDefinitions } from '../types'; import { type Auth, authHeaders, unauthorizedMessage } from './auth'; -const DEFAULT_FETCH_TIMEOUT_MS = 10_000; +export const DEFAULT_FETCH_TIMEOUT_MS = 10_000; const DEFAULT_MAX_ATTEMPTS = 3; class DatafileHttpError extends Error { diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 3f67a045f..d40b88f91 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -74,9 +74,9 @@ type RuntimeSource = 'header' | 'stream' | 'polling'; * - No streaming or polling * * **Runtime — streaming mode** (stream enabled): - * - Uses streaming exclusively; polling is never started, even if configured - * - Init fallback (no data yet): constructor datafile → bundled → throw - * - Read fallback (post-init): in-memory value → constructor datafile → bundled → throw + * - Uses streaming with polling fallback when enabled + * - Retains provided/bundled data during startup; fetches if the cache remains empty + * - Stale reads refresh in the background; expired reads wait for refresh * * **Runtime — polling mode** (polling enabled, stream disabled): * - Uses polling exclusively @@ -112,6 +112,9 @@ export class Controller implements ControllerInterface { private pollingSource: PollingSource; private bundledSource: BundledSource; private headerSource: HeaderSource; + private sourceStartup: Promise | undefined; + // A startup timeout permits cached reads while the first update continues. + private startupFallback = false; // Usage tracking private usageTracker: UsageTracker; @@ -131,9 +134,13 @@ export class Controller implements ControllerInterface { async (signal) => { try { const data = await fetchDatafile({ ...this.options, signal }); + signal.throwIfAborted(); + this.startupFallback = false; this.unauthorized = false; return data; } catch (error) { + signal.throwIfAborted(); + this.startupFallback = false; this.noteUnauthorized(error); throw error; } @@ -150,7 +157,6 @@ export class Controller implements ControllerInterface { this.pollingSource = new PollingSource({ polling: this.options.polling, - staleWhileRevalidateMs: this.options.staleWhileRevalidateMs, refresh: () => this.cache.refresh('poll'), }); this.headerSource = new HeaderSource(this.options); @@ -174,11 +180,15 @@ export class Controller implements ControllerInterface { // Source event handlers (stored for cleanup) private onStreamData = (data: DatafileInput) => { this.unauthorized = false; + this.startupFallback = false; this.cache.updateFromSource(data, 'stream'); }; private onStreamPrimed = (message: PrimedMessage) => { this.unauthorized = false; - this.cache.tryConfirm(message, 'revision'); + if (this.cache.tryConfirm(message, 'revision')) { + this.startupFallback = false; + this.cache.cancelFetch(); + } // The stream is connected even if its revision no longer matches the cache. if (this.state === 'degraded' || this.state === 'initializing:stream') { this.transition('streaming'); @@ -187,6 +197,8 @@ export class Controller implements ControllerInterface { private onStreamPing = () => { // Each connection sends primed/datafile before pings, so a ping confirms recovery. this.cache.confirm(); + this.startupFallback = false; + this.cache.cancelFetch(); }; private onStreamConnected = () => { if (this.state === 'polling') { @@ -309,8 +321,18 @@ export class Controller implements ControllerInterface { return; } + if (!this.options.stream.enabled && !this.options.polling.enabled) { + await this.initializeFromFallbacks(); + return; + } + await this.activateFallbackSource('header'); if (this.cache.hasData) return; + if (this.unauthorized) { + throw this.noDefinitionsError( + '. Provide a datafile or bundled definitions.', + ); + } // All update sources share the same final blocking datafile fetch. const fetched = await this.cache.resolve(this.cacheReadPolicy); @@ -461,10 +483,22 @@ export class Controller implements ControllerInterface { > { if (this.state === 'vercel' && !this.headerSource.isAvailable()) { await this.activateFallbackSource('header'); - } else if (this.state === 'initializing:stream') { - await this.activateFallbackSource('header'); - } else if (this.state === 'initializing:polling') { - await this.activateFallbackSource('stream'); + } else if (this.sourceStartup) { + await this.sourceStartup; + } + + if (!this.cache.hasData && this.unauthorized) { + throw this.noDefinitionsError( + '. Provide a datafile or bundled definitions.', + ); + } + + if ( + !this.cache.hasData && + !this.options.stream.enabled && + !this.options.polling.enabled + ) { + return this.resolveStaticFallbackData(); } const result = await this.cache.resolve(this.cacheReadPolicy); @@ -494,11 +528,14 @@ export class Controller implements ControllerInterface { }; } + if (this.startupFallback) { + return { assess: () => ({ status: 'stale' }) }; + } + if (this.state === 'streaming') { return { assess: this.streamSource.assess }; } - // Seeded initialization can leave the active poller in 'initializing:polling'. if (this.state === 'polling' || this.state === 'initializing:polling') { return { assess: this.pollingSource.assess }; } @@ -510,15 +547,32 @@ export class Controller implements ControllerInterface { * Advances through the runtime source chain. Every caller uses the same path: * request headers → stream → polling → direct cache refresh. */ - private async activateFallbackSource(after: RuntimeSource): Promise { + private activateFallbackSource(after: RuntimeSource): Promise { + if (this.sourceStartup) { + return this.sourceStartup; + } + const startup = this.startFallbackSource(after).finally(() => { + if (this.sourceStartup === startup) { + this.sourceStartup = undefined; + } + }); + this.sourceStartup = startup; + return startup; + } + + private async startFallbackSource(after: RuntimeSource): Promise { if (this.state === 'shutdown') { throw new Error('@vercel/flags-core: Client is shut down'); } if (after === 'header' && this.options.stream.enabled) { this.transition('initializing:stream'); - if (await this.tryInitializeStream()) { - if (!this.isShutdown) this.transition('streaming'); + const connected = await this.tryInitializeStream(); + if (this.isShutdown) { + throw new Error('@vercel/flags-core: Client is shut down'); + } + if (connected) { + this.transition('streaming'); return; } after = 'stream'; @@ -530,12 +584,51 @@ export class Controller implements ControllerInterface { ) { this.pollingSource.startInterval(); this.transition('polling'); + // Stream fallback keeps its interval schedule; primary polling starts now. + if (after === 'header') { + await this.initializePolling(); + } + if (this.isShutdown) { + throw new Error('@vercel/flags-core: Client is shut down'); + } return; } this.transition('degraded'); } + private async initializePolling(): Promise { + const poll = this.pollingSource.poll().catch((error) => { + // Initialization can finish with retained data; serving still enforces SIE. + if (!this.cache.hasData || this.isShutdown) { + throw error; + } + }); + const timeoutMs = this.options.polling.initTimeoutMs; + if (timeoutMs <= 0) { + await poll; + return; + } + + let timeoutId: ReturnType | undefined; + try { + const outcome = await Promise.race([ + poll, + new Promise<'timeout'>((resolve) => { + timeoutId = setTimeout(() => resolve('timeout'), timeoutMs); + }), + ]); + if (outcome === 'timeout') { + this.startupFallback = true; + console.warn( + '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ); + } + } finally { + clearTimeout(timeoutId); + } + } + // --------------------------------------------------------------------------- // Stream initialization // --------------------------------------------------------------------------- @@ -572,6 +665,7 @@ export class Controller implements ControllerInterface { clearTimeout(timeoutId!); if (result === 'timeout') { + this.startupFallback = true; console.warn( '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', ); diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index 7d7e95dde..f536af7b1 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -59,7 +59,8 @@ export type ControllerOptions = { * How long header-driven reads may serve cached data while refreshing in the * background, measured from its last fetch or matching version header. * Accepts finite, non-negative seconds, including fractional seconds. - * Set to 0 to always block on refresh. + * Set to 0 to always block on header-driven refreshes. + * Streaming and polling use freshness windows based on their update schedules. * @default 10 */ staleWhileRevalidate?: number; diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index 7b479e8af..52c5fdb4b 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -1,11 +1,11 @@ import type { CacheAssessment, CacheMetadata } from './datafile-cache'; +import { DEFAULT_FETCH_TIMEOUT_MS } from './fetch-datafile'; import { TypedEmitter } from './typed-emitter'; export type PollingSourceConfig = { polling: { intervalMs: number; }; - staleWhileRevalidateMs: number; refresh: () => Promise; }; @@ -15,7 +15,7 @@ export type PollingSourceEvents = { /** * Manages interval-based polling for flag data. - * Wraps fetchDatafile() and emits typed events. + * Shares the cache's HTTP refresh and emits errors. */ export class PollingSource extends TypedEmitter { private config: PollingSourceConfig; @@ -29,16 +29,15 @@ export class PollingSource extends TypedEmitter { } assess = ({ ageMs }: Pick): CacheAssessment => { - const staleAt = this.config.polling.intervalMs; + // Allow the scheduled poll its entire fetch deadline before revalidating. + const staleAt = this.config.polling.intervalMs + DEFAULT_FETCH_TIMEOUT_MS; if (ageMs <= staleAt) { return { status: 'fresh' }; } - const staleWhileRevalidateAt = staleAt + this.config.staleWhileRevalidateMs; - if ( - this.config.staleWhileRevalidateMs > 0 && - ageMs <= staleWhileRevalidateAt - ) { + // Give the next scheduled poll a chance before making reads block. + const expiresAt = staleAt + this.config.polling.intervalMs; + if (ageMs <= expiresAt) { return { status: 'stale' }; } @@ -47,7 +46,7 @@ export class PollingSource extends TypedEmitter { /** * Perform a single poll request. - * Emits 'data' on success, 'error' on failure. + * Updates the cache on success; emits 'error' and rejects on failure. */ async poll(): Promise { if (this.polling) return this.polling; @@ -64,6 +63,7 @@ export class PollingSource extends TypedEmitter { const err = error instanceof Error ? error : new Error('Unknown poll error'); this.emit('error', err); + throw err; } })().finally(() => { if (this.abortController === controller) this.polling = undefined; diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 8c61928f3..52e49799e 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -19,7 +19,7 @@ export type StreamMessage = const MAX_RETRY_COUNT = 15; const BASE_RETRY_DELAY_MS = 1000; const MAX_RETRY_DELAY_MS = 60_000; -const PING_TIMEOUT_MS = 90_000; +export const PING_TIMEOUT_MS = 90_000; function backoff(retryCount: number): number { if (retryCount === 1) return 0; diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index f20888fa3..9f76d3a61 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -1,7 +1,11 @@ import type { DatafileInput } from '../types'; import type { CacheAssessment, CacheMetadata } from './datafile-cache'; import type { NormalizedOptions } from './normalized-options'; -import { connectStream, type PrimedMessage } from './stream-connection'; +import { + connectStream, + PING_TIMEOUT_MS, + type PrimedMessage, +} from './stream-connection'; import { TypedEmitter } from './typed-emitter'; export type StreamSourceEvents = { @@ -29,15 +33,16 @@ export class StreamSource extends TypedEmitter { this.revision = revision; } - assess = ({ ageMs }: Pick): CacheAssessment => ({ - status: - ageMs <= 30_000 - ? 'fresh' - : this.options.staleWhileRevalidateMs > 0 && - ageMs <= 30_000 + this.options.staleWhileRevalidateMs - ? 'stale' - : 'expired', - }); + assess = ({ ageMs }: Pick): CacheAssessment => { + // Pings arrive every 30s; tolerate one missed ping before revalidating. + if (ageMs <= (PING_TIMEOUT_MS * 2) / 3) { + return { status: 'fresh' }; + } + if (ageMs <= PING_TIMEOUT_MS) { + return { status: 'stale' }; + } + return { status: 'expired' }; + }; /** * Start the stream connection. diff --git a/packages/vercel-flags-core/src/datafile-retries.black-box.test.ts b/packages/vercel-flags-core/src/datafile-retries.black-box.test.ts index 74bf63d8d..c2ac1e76e 100644 --- a/packages/vercel-flags-core/src/datafile-retries.black-box.test.ts +++ b/packages/vercel-flags-core/src/datafile-retries.black-box.test.ts @@ -152,7 +152,9 @@ describe('datafile retries through the public API', () => { if (phase === 'scheduled') await instance.initialize(); dataFetch.mockReset().mockRejectedValue(new Error('Network unavailable')); const initializing = - phase === 'initial' ? instance.initialize() : undefined; + phase === 'initial' + ? expect(instance.initialize()).rejects.toThrow() + : undefined; await vi.advanceTimersByTimeAsync(phase === 'initial' ? 0 : 30_000); expect(dataFetch).toHaveBeenCalledTimes(1); const signal = dataFetch.mock.calls[0]?.[1]?.signal; diff --git a/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts b/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts new file mode 100644 index 000000000..ace25b450 --- /dev/null +++ b/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts @@ -0,0 +1,333 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { + type BundledDefinitions, + createClient, + type FlagsClient, +} from './index.default'; +import { setRequestContext } from './test-utils'; +import { readBundledDefinitions } from './utils/read-bundled-definitions'; + +vi.mock('./utils/read-bundled-definitions', () => ({ + readBundledDefinitions: vi.fn(), +})); + +let warnSpy: ReturnType; +let errorSpy: ReturnType; + +const now = 1_700_000_000_000; +const clients = new Set(); +const dataFetch = vi.fn(); +const streamFetch = vi.fn(); +let cleanContext = () => {}; + +function data(version = 1, enabled = true): BundledDefinitions { + return { + definitions: { + feature: { + environments: { production: enabled ? 1 : 0 }, + variants: [false, true], + }, + }, + segments: {}, + projectId: 'prj_review', + environment: 'production', + digest: String(version), + configUpdatedAt: version, + revision: version, + }; +} + +function context(version?: number) { + cleanContext(); + cleanContext = setRequestContext( + version === undefined + ? {} + : { + 'x-vercel-flags-config-versions': `flags_prj_review=${version}`, + }, + ); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((value) => { + resolve = value; + }); + return { promise, resolve }; +} + +function stream() { + let controller!: ReadableStreamDefaultController; + const body = new ReadableStream({ + start(value) { + controller = value; + }, + }); + return { + response: new Response(body), + push(message: unknown) { + controller.enqueue( + new TextEncoder().encode(`${JSON.stringify(message)}\n`), + ); + }, + close() { + controller.close(); + }, + }; +} + +function client(options: Parameters[1] = {}) { + const instance = createClient('vf_server_review', { + buildStep: false, + vercel: false, + disableMetrics: true, + datafile: data(), + fetch: (input, init) => { + if (String(input).endsWith('/v1/datafile')) { + return dataFetch(input, init); + } + if (String(input).endsWith('/v1/stream')) { + return streamFetch(input, init); + } + return Promise.resolve(new Response()); + }, + ...options, + }); + clients.add(instance); + return instance; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + vi.stubEnv('VERCEL', '0'); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: null, + state: 'missing-file', + }); + dataFetch.mockReset().mockImplementation(async () => Response.json(data())); + streamFetch.mockReset(); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + context(); +}); + +afterEach(async () => { + try { + for (const instance of clients) { + await instance.shutdown(); + } + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + } finally { + clients.clear(); + cleanContext(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + } +}); + +it('refreshes provided data before polling initialize resolves', async () => { + dataFetch.mockResolvedValueOnce(Response.json(data(2, false))); + const instance = client({ + stream: false, + datafile: { ...data(), fetchedAt: now }, + }); + await instance.initialize(); + const snapshot = await instance.getDatafile(); + expect({ + requests: dataFetch.mock.calls.length, + version: snapshot.configUpdatedAt, + }).toEqual({ requests: 1, version: 2 }); +}); + +it('serves cached fallback at the configured polling startup timeout', async () => { + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client({ + stream: false, + polling: { intervalMs: 30_000, initTimeoutMs: 50 }, + }); + const settled = vi.fn(); + const reading = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(50); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ); + warnSpy.mockClear(); + const completedAtTimeout = settled.mock.calls.length; + pending.resolve(Response.json(data(2))); + expect(await reading).toMatchObject({ + value: true, + metrics: { mode: 'polling', cacheStatus: 'STALE' }, + }); + expect(completedAtTimeout).toBe(1); +}); + +it('tolerates a delayed stream ping without starting an early HTTP refresh', async () => { + const live = stream(); + streamFetch.mockResolvedValueOnce(live.response); + const instance = client({ polling: false }); + const initial = instance.evaluate('feature'); + live.push({ type: 'datafile', data: data(2) }); + await initial; + await vi.advanceTimersByTimeAsync(40_001); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const settled = vi.fn(); + const reading = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + const observed = { + requests: dataFetch.mock.calls.length, + completed: settled.mock.calls.length, + }; + pending.resolve(Response.json(data(2))); + expect(await reading).toMatchObject({ + value: true, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + }); + expect(observed).toEqual({ requests: 0, completed: 1 }); +}); + +it('does not invalidate a healthy stream when a retired header fetch fails late', async () => { + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client({ vercel: true, staleIfError: 0 }); + context(2); + const original = instance.evaluate('feature', false); + await vi.advanceTimersByTimeAsync(0); + const live = stream(); + streamFetch.mockResolvedValueOnce(live.response); + context(); + const switching = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(0); + live.push({ type: 'datafile', data: data(3) }); + expect((await switching).value).toBe(true); + expect(await original).toMatchObject({ + value: true, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + }); + expect(dataFetch.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + pending.resolve( + new Response(null, { status: 401, statusText: 'Unauthorized' }), + ); + await vi.advanceTimersByTimeAsync(0); + const result = await instance.evaluate('feature', false); + expect(result).toMatchObject({ value: true, metrics: { mode: 'streaming' } }); +}); + +it('cancels pending polling work when a stream reconnects', async () => { + const first = stream(); + const second = stream(); + streamFetch + .mockResolvedValueOnce(first.response) + .mockResolvedValueOnce(second.response); + const instance = client({ staleIfError: 0 }); + const initial = instance.evaluate('feature'); + first.push({ type: 'datafile', data: data(2) }); + await initial; + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + first.close(); + await vi.advanceTimersByTimeAsync(30_000); + const signal = dataFetch.mock.calls[0]?.[1]?.signal; + expect(dataFetch).toHaveBeenCalledTimes(1); + second.push({ + type: 'primed', + revision: 2, + projectId: 'prj_review', + environment: 'production', + }); + await vi.advanceTimersByTimeAsync(0); + const abortedOnReconnect = signal?.aborted; + pending.resolve( + new Response(null, { status: 401, statusText: 'Unauthorized' }), + ); + await vi.advanceTimersByTimeAsync(0); + const result = await instance.evaluate('feature', false); + expect({ abortedOnReconnect, value: result.value }).toEqual({ + abortedOnReconnect: true, + value: true, + }); +}); + +it('does not restart polling after shutdown during missing-header stream startup', async () => { + const live = stream(); + streamFetch.mockResolvedValueOnce(live.response); + const instance = client({ vercel: true }); + await instance.initialize(); + context(); + const reading = expect(instance.evaluate('feature')).rejects.toThrow( + '@vercel/flags-core: Client is shut down', + ); + await vi.advanceTimersByTimeAsync(0); + await instance.shutdown(); + clients.delete(instance); + await vi.advanceTimersByTimeAsync(30_001); + await reading; + expect({ + requests: dataFetch.mock.calls.length, + timers: vi.getTimerCount(), + }).toEqual({ requests: 0, timers: 0 }); +}); + +it.each([ + ['streaming', 30_000, 60_000, 90_000], + ['polling', 30_000, 40_000, 70_000], + ['polling', 45_000, 55_000, 100_000], +] as const)('uses background then blocking refresh for %s at interval %i', async (mode, intervalMs, staleAt, expiresAt) => { + const live = stream(); + streamFetch.mockResolvedValueOnce(live.response); + const instance = client({ + stream: mode === 'streaming', + polling: { intervalMs, initTimeoutMs: 3_000 }, + // This controls headers; scheduled sources use their own timing windows. + staleWhileRevalidate: 0, + }); + const initial = instance.evaluate('feature'); + if (mode === 'streaming') { + live.push({ type: 'datafile', data: data() }); + } + await initial; + const initialRequests = mode === 'polling' ? 1 : 0; + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + + // Move wall-clock age independently of timers to model a suspended runtime. + vi.setSystemTime(now + staleAt); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe('HIT'); + expect(dataFetch).toHaveBeenCalledTimes(initialRequests); + vi.setSystemTime(now + staleAt + 1); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect(dataFetch).toHaveBeenCalledTimes(initialRequests + 1); + + vi.setSystemTime(now + expiresAt); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + vi.setSystemTime(now + expiresAt + 1); + const settled = vi.fn(); + const reading = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(initialRequests + 1); + pending.resolve(Response.json(data(2, false))); + expect(await reading).toMatchObject({ + value: false, + metrics: { cacheStatus: 'MISS' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(initialRequests + 1); +}); diff --git a/packages/vercel-flags-core/src/stale-if-error.test.ts b/packages/vercel-flags-core/src/stale-if-error.test.ts index e2db1f3f9..d238ea0ae 100644 --- a/packages/vercel-flags-core/src/stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stale-if-error.test.ts @@ -130,7 +130,7 @@ describe('polling stale-if-error through the public API', () => { expect(initial.metrics).toEqual({ readMs: 0, evaluationMs: 0, - source: 'remote', + source: 'in-memory', cacheStatus: 'HIT', connectionState: 'disconnected', mode: 'polling', @@ -190,7 +190,7 @@ describe('polling stale-if-error through the public API', () => { expect(poll).toHaveBeenCalledTimes(2); }); - it('marks data stale after the polling interval and resets age on an equal response', async () => { + it('allows the polling fetch deadline before becoming stale and resets age on an equal response', async () => { const instance = client({ polling: { intervalMs: 45_000, initTimeoutMs: 3_000 }, }); @@ -202,7 +202,7 @@ describe('polling stale-if-error through the public API', () => { await vi.advanceTimersByTimeAsync(45_000); expect(await instance.evaluate('flagA')).toEqual(initial); expect((await instance.getDatafile()).metrics.cacheStatus).toBe('HIT'); - await vi.advanceTimersByTimeAsync(1); + vi.setSystemTime(55_001); expect(await instance.evaluate('flagA')).toEqual({ ...initial, metrics: { ...initial.metrics, cacheStatus: 'STALE' }, @@ -241,7 +241,7 @@ describe('polling stale-if-error through the public API', () => { readMs: 0, evaluationMs: 0, source: seed === 'provided' ? 'in-memory' : 'embedded', - cacheStatus: 'MISS', + cacheStatus: 'HIT', connectionState: 'disconnected', mode: 'polling', }); @@ -267,7 +267,7 @@ describe('polling stale-if-error through the public API', () => { }); expect(await instance.getDatafile()).toEqual(snapshot); expect(poll).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(1); + vi.setSystemTime(155_001); expect(await instance.evaluate('flagA')).toEqual({ ...initial, metrics: { ...initial.metrics, cacheStatus: 'STALE' }, @@ -305,7 +305,7 @@ describe('polling stale-if-error through the public API', () => { const revalidation = deferred(); poll.mockResolvedValueOnce(response(data(override))); poll.mockReturnValueOnce(revalidation.promise); - await vi.advanceTimersByTimeAsync(45_001); + await vi.advanceTimersByTimeAsync(55_001); expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe( 'STALE', ); @@ -420,7 +420,7 @@ describe('polling stale-if-error through the public API', () => { readMs: 0, evaluationMs: 0, source: 'embedded', - cacheStatus: 'MISS', + cacheStatus: 'HIT', connectionState: 'disconnected', mode: 'polling', }); @@ -500,21 +500,32 @@ describe('polling stale-if-error through the public API', () => { ); }); - it('uses the datafile fetch deadline for a blocking refresh', async () => { + it('serves startup fallback at initTimeoutMs without treating the timeout as a failure', async () => { const pending = deferred(); poll.mockReturnValue(pending.promise); const instance = client({ staleIfError: 0, datafile: data() }); const evaluation = instance.evaluate('flagA'); const settled = vi.fn(); void evaluation.then(settled, settled); - const outcome = expect(evaluation).rejects.toThrow( - '@vercel/flags-core: Datafile fetch deadline exceeded', - ); - await vi.advanceTimersByTimeAsync(9_999); + await vi.advanceTimersByTimeAsync(2_999); expect(settled).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); - await outcome; - expect(warnSpy).not.toHaveBeenCalled(); + expect((await evaluation).value).toBe(true); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ); + warnSpy.mockClear(); + await vi.advanceTimersByTimeAsync(7_000); + await expect(instance.evaluate('flagA')).rejects.toThrow( + '@vercel/flags-core: Datafile fetch deadline exceeded', + ); + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Revalidation failed:', + expect.objectContaining({ + message: '@vercel/flags-core: Datafile fetch deadline exceeded', + }), + ); + errorSpy.mockClear(); expect(poll).toHaveBeenCalledTimes(1); }); @@ -596,10 +607,10 @@ describe('polling stale-if-error through the public API', () => { await vi.advanceTimersByTimeAsync(60_000); expect(await instance.evaluate('flagA')).toEqual({ ...initial, - metrics: { ...initial.metrics, cacheStatus: 'MISS' }, + metrics: { ...initial.metrics, cacheStatus: 'HIT' }, }); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(poll).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(poll).not.toHaveBeenCalled(); }); it('applies zero allowance to an initial stream error through the shared fallback', async () => { diff --git a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts index 699fa0f11..fbb5c14aa 100644 --- a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts @@ -163,7 +163,7 @@ describe('stream stale-if-error through the public API', () => { const { instance, stream } = await start({ staleIfError: 0 }); const initial = await instance.evaluate('flagA'); const snapshot = await instance.getDatafile(); - await vi.advanceTimersByTimeAsync(30_000); + await vi.advanceTimersByTimeAsync(60_000); expect(await instance.evaluate('flagA')).toEqual(initial); expect((await instance.getDatafile()).metrics.cacheStatus).toBe('HIT'); await vi.advanceTimersByTimeAsync(1); @@ -180,7 +180,7 @@ describe('stream stale-if-error through the public API', () => { expect(confirmed).toEqual(snapshot); expect(confirmed.definitions).toBe(snapshot.definitions); expect(confirmed.fetchedAt).toBe(0); - await vi.advanceTimersByTimeAsync(30_000); + await vi.advanceTimersByTimeAsync(60_000); expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe('HIT'); await vi.advanceTimersByTimeAsync(1); expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe( @@ -192,7 +192,7 @@ describe('stream stale-if-error through the public API', () => { it('does not renew stream freshness on an invalid confirmation', async () => { const { instance, stream } = await start(); const snapshot = await instance.getDatafile(); - await vi.advanceTimersByTimeAsync(30_001); + await vi.advanceTimersByTimeAsync(60_001); for (const [index, override] of [ { revision: 6 }, { projectId: 'other' }, @@ -260,7 +260,7 @@ describe('stream stale-if-error through the public API', () => { metrics: { mode: 'streaming', connectionState: 'connected', - cacheStatus: 'HIT', + cacheStatus: 'STALE', }, }); expect((await instance.getDatafile()).configUpdatedAt).toBe(10); diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 2b5a411a8..90a6977c1 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -232,8 +232,8 @@ describe('Vercel mode (black-box)', () => { value: true, metrics: { mode, - source: mode === 'streaming' ? 'in-memory' : 'remote', - cacheStatus: mode === 'streaming' ? 'HIT' : 'MISS', + source: 'in-memory', + cacheStatus: 'HIT', }, }); expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); @@ -269,7 +269,8 @@ describe('Vercel mode (black-box)', () => { 'absent', 'empty', 'no context', - ])('serves a fresh cache while switching to polling after a header becomes %s', async (header) => { + ])('shares bounded polling startup while switching after a header becomes %s', async (header) => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const instance = client({ stream: false }); expect((await instance.evaluate('feature')).metrics?.mode).toBe('vercel'); cleanupContext(); @@ -285,17 +286,20 @@ describe('Vercel mode (black-box)', () => { setVersion(TIMESTAMP + 100); const second = instance.bulkEvaluate([{ key: 'feature' }]); await vi.advanceTimersByTimeAsync(0); - expect(dataFetch).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(3_000); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ); expect(await first).toMatchObject({ value: false, - metrics: { mode: 'polling', cacheStatus: 'HIT' }, + metrics: { mode: 'polling', cacheStatus: 'STALE' }, }); expect((await second).feature).toMatchObject({ value: false, - metrics: { mode: 'polling', cacheStatus: 'HIT' }, + metrics: { mode: 'polling', cacheStatus: 'STALE' }, }); - await vi.advanceTimersByTimeAsync(30_000); pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); await vi.advanceTimersByTimeAsync(0); expect(await instance.evaluate('feature')).toMatchObject({ @@ -312,7 +316,12 @@ describe('Vercel mode (black-box)', () => { ] as const)('shares a pending %s cache refresh while switching to polling', async (outcome) => { const pending = deferred(); dataFetch.mockReturnValueOnce(pending.promise); - const instance = client({ stream: false, staleIfError: 0 }); + const instance = client({ + stream: false, + staleIfError: 0, + staleWhileRevalidate: 0, + datafile: { ...datafile(), fetchedAt: TIMESTAMP }, + }); setVersion(TIMESTAMP + 1); const originalRead = instance.evaluate('feature'); await vi.advanceTimersByTimeAsync(0); @@ -333,7 +342,7 @@ describe('Vercel mode (black-box)', () => { }); expect(await switchingRead).toMatchObject({ value: outcome === 'newer', - metrics: { mode: 'polling', cacheStatus: 'MISS' }, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, }); expect((await instance.getDatafile()).configUpdatedAt).toBe( @@ -380,7 +389,7 @@ describe('Vercel mode (black-box)', () => { ['streaming', 'bundled'], ['polling', 'provided'], ['polling', 'bundled'], - ] as const)('refreshes after %s becomes too old and retains newer data over the original %s seed', async (mode, seed) => { + ] as const)('retains newer data over the original %s %s seed at the startup timeout', async (mode, seed) => { vi.mocked(readBundledDefinitions).mockResolvedValue({ definitions: datafile(), state: 'ok', @@ -407,13 +416,13 @@ describe('Vercel mode (black-box)', () => { const settled = vi.fn(); void reading.then(settled); await vi.advanceTimersByTimeAsync(3_000); - expect(settled).not.toHaveBeenCalled(); + expect(settled).toHaveBeenCalledTimes(1); pendingPoll.resolve(Response.json(datafile())); expect(await reading).toMatchObject({ value: true, metrics: { source: 'remote', - cacheStatus: 'MISS', + cacheStatus: 'STALE', }, }); if (mode === 'streaming') { @@ -421,7 +430,9 @@ describe('Vercel mode (black-box)', () => { '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', ); } else { - expect(warnSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ); } const retained = await instance.getDatafile(); expect(retained.configUpdatedAt).toBe(TIMESTAMP + 1); @@ -494,13 +505,14 @@ describe('Vercel mode (black-box)', () => { // The shared cache records the failure after transport retries finish. vi.setSystemTime(TIMESTAMP + 1_301); setVersion(undefined); + rejectDatafileOnce(new Error('fallback poll failure')); const secondFailure = expect(instance.evaluate('feature')).rejects.toBe( firstError, ); await vi.advanceTimersByTimeAsync(300); await secondFailure; await expect(instance.getDatafile()).rejects.toBe(firstError); - expect(dataFetch).toHaveBeenCalledTimes(4); + expect(dataFetch).toHaveBeenCalledTimes(7); mockDatafileResponse(TIMESTAMP + 1, true); await vi.advanceTimersByTimeAsync(30_000); @@ -511,11 +523,12 @@ describe('Vercel mode (black-box)', () => { const recovered = await instance.getDatafile(); expect(recovered.definitions).toBe(snapshot.definitions); expect(recovered.fetchedAt).toBe(snapshot.fetchedAt); - expect(dataFetch).toHaveBeenCalledTimes(5); + expect(dataFetch).toHaveBeenCalledTimes(8); expect(streamFetch).not.toHaveBeenCalled(); }); it('continues polling after the first fallback poll reaches its fetch deadline', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const firstPoll = deferred(); dataFetch.mockReturnValueOnce(firstPoll.promise); @@ -527,7 +540,15 @@ describe('Vercel mode (black-box)', () => { value: false, metrics: { mode: 'polling', cacheStatus: 'STALE' }, }); - expect(warnSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ); + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Revalidation failed:', + expect.objectContaining({ + message: '@vercel/flags-core: Datafile fetch deadline exceeded', + }), + ); mockDatafileResponse(TIMESTAMP + 2, true); await vi.advanceTimersByTimeAsync(20_000); @@ -552,7 +573,7 @@ describe('Vercel mode (black-box)', () => { await vi.advanceTimersByTimeAsync(3_000); expect(await reading).toMatchObject({ value: false, - metrics: { mode: 'polling', cacheStatus: 'MISS' }, + metrics: { mode: 'polling', cacheStatus: 'STALE' }, }); expect(warnSpy).toHaveBeenCalledExactlyOnceWith( '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', @@ -956,15 +977,16 @@ describe('Vercel mode (black-box)', () => { setVersion(); mockDatafileResponse(TIMESTAMP + 60_000, true); expect(await instance.evaluate('feature')).toMatchObject({ - value: false, + value: true, metrics: { mode: 'polling', cacheStatus: 'HIT' }, }); + mockDatafileResponse(TIMESTAMP + 80_000, false); await vi.advanceTimersByTimeAsync(30_000); expect(await instance.evaluate('feature')).toMatchObject({ - value: true, + value: false, metrics: { mode: 'polling', cacheStatus: 'HIT' }, }); - expect(dataFetch).toHaveBeenCalledTimes(3); + expect(dataFetch).toHaveBeenCalledTimes(4); }); it('does not make a fresh request wait on another requests blocking refresh', async () => { From 4abc08b04a1a289d62c1248ac7de917f5dd9d668 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 1 Oct 2026 12:45:59 +0200 Subject: [PATCH 30/41] refactor(flags-core): centralize cancellation in cache confirmation --- packages/vercel-flags-core/CLAUDE.md | 7 +- .../src/controller/datafile-cache.ts | 32 ++++----- .../vercel-flags-core/src/controller/index.ts | 6 +- .../src/source-lifecycle.black-box.test.ts | 70 +++++++++++++++++++ 4 files changed, 91 insertions(+), 24 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 7a101549d..e5d71ca16 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -286,7 +286,7 @@ When updating tests for new behavior, preserve the strength of existing assertio - Default `initTimeoutMs`: 3000ms (3s) - Datafile fetches use three total attempts with 100ms and 200ms backoff for network, token, body parsing, and transient HTTP failures (408, 429, and 5xx). Other HTTP errors fail immediately. After exhausted retries, polling emits an error event and waits for the next interval. - Stops automatically when stream reconnects -- `PollingSource` shares the cache's HTTP refresh for initialization and scheduled polls. The controller cancels superseded refreshes on stream confirmation and clears them on shutdown; stopping the poller suppresses errors from its pending work. +- `PollingSource` shares the cache's HTTP refresh for initialization and scheduled polls. Cache confirmation cancels superseded refreshes for stream evidence; the controller clears them on shutdown. Stopping the poller suppresses errors from its pending work. - Initialization waits for the first poll up to `initTimeoutMs`. A timeout permits cached fallback without renewing cache age or failure allowance; the pending poll and recurring interval continue. - `fetchDatafile` owns a ten-second deadline covering token resolution, all attempts and backoff, and body parsing. It settles on timeout or cancellation even when a transport ignores its signal, and preserves the external abort reason. - Retries are enabled by default for every `fetchDatafile` caller: polling, build loading, offline initialization/evaluation, and direct `getDatafile()` fallback. Internal callers can override `maxAttempts`; retry scheduling and deadline handling remain in the fetch helper, independently of source classes and cache policy. @@ -345,6 +345,11 @@ version/revision confirmations clear it; repeated errors/disconnects do not rene the first-error deadline. Stream opening and initialization timeout alone are not recovery/failure evidence respectively. +`tryConfirm()` validates version and identity, then delegates to `confirm(source)`. +Confirmation owns freshness, recovery, and cancellation: stream evidence cancels +superseded HTTP work only after data is accepted or validated. HTTP responses finish +their own refresh, and request-header confirmations do not cancel pending work. + `cache.resolve(policy)` receives a mode-specific `assess` callback returning `{ status, confirmed? }`, where status is `fresh`, `stale`, `expired`, or `unknown`. The shared fetch callback is configured once on the cache. diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index d2168249f..9b85761bb 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -5,6 +5,7 @@ type Confirmation = Pick< DatafileInput, 'configUpdatedAt' | 'revision' | 'projectId' | 'environment' >; +type ConfirmationSource = DataOrigin | 'header'; export type CacheMetadata = Confirmation & { ageMs: number }; @@ -108,21 +109,17 @@ export class DatafileCache { updateFromSource(incoming: DatafileInput, origin: DataOrigin): void { if (this.isNewerData(incoming)) { this.data = tagData({ ...incoming, fetchedAt: Date.now() }, origin); - this.confirm(); - if (origin === 'stream') { - this.cancelFetch(); - } + this.confirm(origin); return; } - if (this.tryConfirm(incoming) && origin === 'stream') { - this.cancelFetch(); - } + this.tryConfirm(incoming, 'configUpdatedAt', origin); } /** Confirms a same-version source response without replacing stored data. */ tryConfirm( incoming: Confirmation, version: 'configUpdatedAt' | 'revision' = 'configUpdatedAt', + source: ConfirmationSource = 'header', ): boolean { if (!this.data) return false; @@ -144,14 +141,20 @@ export class DatafileCache { return false; } - this.confirm(); + this.confirm(source); return true; } - /** Confirms the current cache state by clearing failures and resetting age. */ - confirm(): void { + /** Renews freshness and retires HTTP work superseded by stream evidence. */ + confirm(source: ConfirmationSource = 'header'): void { this.resetAge(); this.failure = undefined; + // HTTP responses must finish their own refresh; headers may be older requests. + if (source === 'stream' && this.fetching) { + this.abortController.abort(SOURCE_CONFIRMED); + this.abortController = new AbortController(); + this.fetching = undefined; + } } /** Preserves existing acceptance, including missing or unparseable versions. */ @@ -310,13 +313,4 @@ export class DatafileCache { this.data = undefined; this.freshAt = undefined; } - - /** Retire superseded HTTP work without discarding the accepted snapshot. */ - cancelFetch(): void { - if (this.fetching) { - this.abortController.abort(SOURCE_CONFIRMED); - this.abortController = new AbortController(); - this.fetching = undefined; - } - } } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index d40b88f91..f28f9dfbe 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -185,9 +185,8 @@ export class Controller implements ControllerInterface { }; private onStreamPrimed = (message: PrimedMessage) => { this.unauthorized = false; - if (this.cache.tryConfirm(message, 'revision')) { + if (this.cache.tryConfirm(message, 'revision', 'stream')) { this.startupFallback = false; - this.cache.cancelFetch(); } // The stream is connected even if its revision no longer matches the cache. if (this.state === 'degraded' || this.state === 'initializing:stream') { @@ -196,9 +195,8 @@ export class Controller implements ControllerInterface { }; private onStreamPing = () => { // Each connection sends primed/datafile before pings, so a ping confirms recovery. - this.cache.confirm(); + this.cache.confirm('stream'); this.startupFallback = false; - this.cache.cancelFetch(); }; private onStreamConnected = () => { if (this.state === 'polling') { diff --git a/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts b/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts index ace25b450..fe9eddd41 100644 --- a/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts +++ b/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts @@ -141,6 +141,7 @@ it('refreshes provided data before polling initialize resolves', async () => { requests: dataFetch.mock.calls.length, version: snapshot.configUpdatedAt, }).toEqual({ requests: 1, version: 2 }); + expect(dataFetch.mock.calls[0]?.[1]?.signal?.aborted).toBe(false); }); it('serves cached fallback at the configured polling startup timeout', async () => { @@ -259,6 +260,75 @@ it('cancels pending polling work when a stream reconnects', async () => { }); }); +it.each([ + ['equal datafile', { type: 'datafile', data: data(2) }, true], + [ + 'matching revision', + { + type: 'primed', + revision: 2, + projectId: 'prj_review', + environment: 'production', + }, + true, + ], + ['ping', { type: 'ping' }, true], + ['older datafile', { type: 'datafile', data: data(1) }, false], + [ + 'mismatched revision', + { + type: 'primed', + revision: 1, + projectId: 'prj_review', + environment: 'production', + }, + false, + ], +] as const)('only supersedes a pending refresh with valid stream evidence: %s', async (_kind, message, confirms) => { + const live = stream(); + streamFetch.mockResolvedValueOnce(live.response); + const instance = client({ polling: false, staleIfError: 0 }); + const initial = instance.evaluate('feature'); + live.push({ type: 'datafile', data: data(2) }); + await initial; + + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + vi.setSystemTime(now + 90_001); + const settled = vi.fn(); + const reading = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); + const signal = dataFetch.mock.calls[0]?.[1]?.signal; + + live.push(message); + await vi.advanceTimersByTimeAsync(0); + expect(signal?.aborted).toBe(confirms); + if (confirms) { + expect(settled).toHaveBeenCalledTimes(1); + expect(await reading).toMatchObject({ + value: true, + metrics: { cacheStatus: 'HIT' }, + }); + pending.resolve(new Response(null, { status: 401 })); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.evaluate('feature')).value).toBe(true); + } else { + expect(settled).not.toHaveBeenCalled(); + pending.resolve(Response.json(data(3, false))); + expect(await reading).toMatchObject({ + value: false, + metrics: { cacheStatus: 'MISS' }, + }); + expect(signal?.aborted).toBe(false); + } + expect(dataFetch).toHaveBeenCalledTimes(1); +}); + it('does not restart polling after shutdown during missing-header stream startup', async () => { const live = stream(); streamFetch.mockResolvedValueOnce(live.response); From 1d571dd39b212b967a934e513f5339ce33a8cbcb Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 1 Oct 2026 12:53:20 +0200 Subject: [PATCH 31/41] refactor(flags-core): keep polling initialization in its original section --- .../vercel-flags-core/src/controller/index.ts | 74 +++++++++++-------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index f28f9dfbe..c959519b9 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -595,38 +595,6 @@ export class Controller implements ControllerInterface { this.transition('degraded'); } - private async initializePolling(): Promise { - const poll = this.pollingSource.poll().catch((error) => { - // Initialization can finish with retained data; serving still enforces SIE. - if (!this.cache.hasData || this.isShutdown) { - throw error; - } - }); - const timeoutMs = this.options.polling.initTimeoutMs; - if (timeoutMs <= 0) { - await poll; - return; - } - - let timeoutId: ReturnType | undefined; - try { - const outcome = await Promise.race([ - poll, - new Promise<'timeout'>((resolve) => { - timeoutId = setTimeout(() => resolve('timeout'), timeoutMs); - }), - ]); - if (outcome === 'timeout') { - this.startupFallback = true; - console.warn( - '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', - ); - } - } finally { - clearTimeout(timeoutId); - } - } - // --------------------------------------------------------------------------- // Stream initialization // --------------------------------------------------------------------------- @@ -682,6 +650,48 @@ export class Controller implements ControllerInterface { } } + // --------------------------------------------------------------------------- + // Polling initialization + // --------------------------------------------------------------------------- + + /** + * Waits for the first poll when polling is the primary runtime source. + * On timeout, initialization falls back while the pending poll and interval + * continue in the background. Poll errors propagate if no data is cached or + * the client is shutting down. + */ + private async initializePolling(): Promise { + const poll = this.pollingSource.poll().catch((error) => { + // Initialization can finish with retained data; serving still enforces SIE. + if (!this.cache.hasData || this.isShutdown) { + throw error; + } + }); + const timeoutMs = this.options.polling.initTimeoutMs; + if (timeoutMs <= 0) { + await poll; + return; + } + + let timeoutId: ReturnType | undefined; + try { + const outcome = await Promise.race([ + poll, + new Promise<'timeout'>((resolve) => { + timeoutId = setTimeout(() => resolve('timeout'), timeoutMs); + }), + ]); + if (outcome === 'timeout') { + this.startupFallback = true; + console.warn( + '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ); + } + } finally { + clearTimeout(timeoutId); + } + } + private noteUnauthorized(error: unknown): void { if ( error instanceof UnauthorizedError || From 7b57af513124e0f37d355fa4ae9d3b6334ff9a33 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 1 Oct 2026 12:59:45 +0200 Subject: [PATCH 32/41] test(flags-core): await background refresh before stream failure --- .../vercel-flags-core/src/stream-stale-if-error.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts index fbb5c14aa..0e5cd8722 100644 --- a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts @@ -525,7 +525,8 @@ describe('stream stale-if-error through the public API', () => { const stream = mockStream(); streamFetch.mockResolvedValueOnce(stream.response); const supplied = data(); - const instance = client({ datafile: supplied, staleIfError: 0 }); + const waitUntil = vi.fn(); + const instance = client({ datafile: supplied, staleIfError: 0, waitUntil }); const initialized = vi.fn(); const initialization = Promise.resolve(instance.initialize()).then( initialized, @@ -540,6 +541,10 @@ describe('stream stale-if-error through the public API', () => { expectInitTimeout(); await vi.advanceTimersByTimeAsync(10_000); expect((await instance.evaluate('flagA')).value).toBe(true); + // Finish the read-triggered refresh before introducing a stream failure. + // Otherwise its successful response can recover the cache after the failure. + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); + await waitUntil.mock.calls[0]![0]; expect((await instance.getDatafile()).definitions).toBe( supplied.definitions, ); From fcb41dfbcee00c033ff7902fecf6a7e0e564b0d0 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 1 Oct 2026 13:46:28 +0200 Subject: [PATCH 33/41] fix(flags-core): reconnect quietly after stream ping timeouts --- .changeset/header-driven-vercel-mode.md | 2 + packages/vercel-flags-core/CLAUDE.md | 4 +- .../vercel-flags-core/src/black-box.test.ts | 23 +- .../src/controller/stream-connection.test.ts | 4 +- .../src/controller/stream-connection.ts | 33 +-- .../src/source-lifecycle.black-box.test.ts | 202 ++++++++++++++++++ .../src/stream-stale-if-error.test.ts | 18 +- 7 files changed, 255 insertions(+), 31 deletions(-) diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index 2c34ae436..82f373132 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -8,4 +8,6 @@ An evaluation with a missing or empty version header permanently starts streamin The controller supplies a source freshness-status callback to the cache and configures one shared fetch callback. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Streaming becomes stale after 60 seconds and expires after 90 seconds. Polling becomes stale after its interval plus the 10-second fetch deadline and expires after two intervals plus that deadline (40/70 seconds by default). Stale evaluations refresh in the background; expired evaluations block on the shared refresh. Stream pings reset age and clear failures. Polling initialization honors its configured timeout while preserving the pending poll and interval; cached fallback does not renew cache age or stale-if-error. +Ping timeouts reconnect the stream internally, allowing suspended runtimes to resume without starting polling. Reconnection alone does not renew cache freshness. Connection errors, server closure, and exhausted retries still trigger the existing polling fallback. + Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. `staleWhileRevalidate` controls header-driven refreshes; stream/poll freshness follows their update schedules. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index e5d71ca16..e05a1950f 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -275,7 +275,8 @@ When updating tests for new behavior, preserve the strength of existing assertio - Retries on transient errors both before and after initial data is received. Before initial data, retries continue until max retries are exhausted or the abort controller is aborted (e.g., by the Controller's init timeout). The init promise rejects when the loop exits without data. - Default `initTimeoutMs`: 3000ms - 401 errors abort immediately (invalid SDK key) and reject the init promise, so fallback kicks in without waiting for the stream timeout -- On disconnect: state transitions to `'degraded'`, falls back to polling if enabled +- A ping timeout reconnects the transport internally without emitting a disconnect or starting polling, including when a suspended runtime resumes. Replacement streams keep a watchdog before their first message. Reconnecting alone does not renew cache age or clear failures; stale/expired reads still refresh through HTTP. +- On connection errors, server closure, or retry exhaustion: state transitions to `'degraded'`, falls back to polling if enabled - On reconnect: Controller listens for `'connected'` event and transitions back to `'streaming'` - Background stream promises (from init timeout) are `.catch`-ed by the Controller to prevent unhandled rejections when the stream is aborted before receiving data @@ -288,6 +289,7 @@ When updating tests for new behavior, preserve the strength of existing assertio - Stops automatically when stream reconnects - `PollingSource` shares the cache's HTTP refresh for initialization and scheduled polls. Cache confirmation cancels superseded refreshes for stream evidence; the controller clears them on shutdown. Stopping the poller suppresses errors from its pending work. - Initialization waits for the first poll up to `initTimeoutMs`. A timeout permits cached fallback without renewing cache age or failure allowance; the pending poll and recurring interval continue. +- After runtime suspension, delayed intervals resume polling without changing sources. A request pending across suspension can hit its fetch deadline; the interval continues and a later successful poll clears the failure. - `fetchDatafile` owns a ten-second deadline covering token resolution, all attempts and backoff, and body parsing. It settles on timeout or cancellation even when a transport ignores its signal, and preserves the external abort reason. - Retries are enabled by default for every `fetchDatafile` caller: polling, build loading, offline initialization/evaluation, and direct `getDatafile()` fallback. Internal callers can override `maxAttempts`; retry scheduling and deadline handling remain in the fetch helper, independently of source classes and cache policy. diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 3715b0360..07a589850 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -2118,14 +2118,27 @@ describe('Controller (black-box)', () => { await vi.advanceTimersByTimeAsync(90_000); await vi.advanceTimersByTimeAsync(0); - // Should have transitioned to degraded - const result2 = await client.evaluate('flagA'); - expect(result2.metrics?.connectionState).toBe('disconnected'); + // Renew the zombie transport without degrading the selected source. + const snapshot = await client.getDatafile(); + expect(snapshot.metrics).toMatchObject({ + mode: 'streaming', + connectionState: 'connected', + cacheStatus: 'STALE', + }); + expect(streamCount).toBe(2); - // Should have attempted reconnection - expect(streamCount).toBeGreaterThanOrEqual(2); + streams[1]!.push({ type: 'datafile', data: datafile }); + await vi.advanceTimersByTimeAsync(0); + const result2 = await client.evaluate('flagA'); + expect(result2.value).toBe(result1.value); + expect(result2.metrics).toMatchObject({ + mode: 'streaming', + connectionState: 'connected', + cacheStatus: 'HIT', + }); await client.shutdown(); + expect(errorSpy).not.toHaveBeenCalled(); errorSpy.mockRestore(); }); diff --git a/packages/vercel-flags-core/src/controller/stream-connection.test.ts b/packages/vercel-flags-core/src/controller/stream-connection.test.ts index 172895d77..e6c3861f4 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.test.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.test.ts @@ -753,10 +753,10 @@ describe('connectStream', () => { await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(0); - expect(onDisconnect).toHaveBeenCalled(); + expect(onDisconnect).not.toHaveBeenCalled(); // Should have attempted reconnection - expect(requestCount).toBeGreaterThanOrEqual(2); + expect(requestCount).toBe(2); abortController.abort(); }); diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 52e49799e..0a275f6fa 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -20,6 +20,7 @@ const MAX_RETRY_COUNT = 15; const BASE_RETRY_DELAY_MS = 1000; const MAX_RETRY_DELAY_MS = 60_000; export const PING_TIMEOUT_MS = 90_000; +const PING_TIMEOUT = new Error('stream: ping timeout'); function backoff(retryCount: number): number { if (retryCount === 1) return 0; @@ -109,6 +110,9 @@ export async function connectStream( new Error('stream: max retry count exceeded before receiving data'), ); } + // Silent reconnects may exhaust retries without an earlier disconnect. + reportError(new Error('stream: max retry count exceeded')); + onDisconnect?.(); abortController.abort(); break; } @@ -122,15 +126,12 @@ export async function connectStream( }); let pingTimeoutId: ReturnType | undefined; - // Reference to the response body so the ping timeout can cancel it - // to break out of the for-await loop. - let responseBody: ReadableStream | undefined; const resetPingTimeout = (): void => { if (pingTimeoutId !== undefined) clearTimeout(pingTimeoutId); if (!initialDataReceived) return; pingTimeoutId = setTimeout(() => { - responseBody?.cancel().catch(() => {}); - connectionAbort.abort(); + lastError = PING_TIMEOUT; + connectionAbort.abort(PING_TIMEOUT); }, PING_TIMEOUT_MS); }; @@ -173,6 +174,7 @@ export async function connectStream( if (!initialDataReceived) { rejectInit!(error); } + onDisconnect?.(); abortController.abort(); break; } @@ -185,7 +187,6 @@ export async function connectStream( throw new Error('stream body was not present'); } - responseBody = response.body; const reader = response.body.getReader(); const decoder = new TextDecoder(); const bufferChunks: string[] = []; @@ -198,11 +199,15 @@ export async function connectStream( connectionAbort.signal.addEventListener('abort', onConnectionAbort, { once: true, }); + // Replacement connections also need a watchdog before their first message. + resetPingTimeout(); try { while (true) { const { done, value: chunk } = await reader.read(); - if (done || abortController.signal.aborted) break; + if (done || connectionAbort.signal.aborted) { + break; + } bufferChunks.push(decoder.decode(chunk, { stream: true })); const combined = bufferChunks.join(''); @@ -266,7 +271,11 @@ export async function connectStream( clearTimeout(pingTimeoutId); abortController.signal.removeEventListener('abort', onMainAbort); if (!abortController.signal.aborted) { - onDisconnect?.(); + // A suspended runtime can resume with an overdue heartbeat timer. + // Renew the transport internally, without triggering source fallback. + if (connectionAbort.signal.reason !== PING_TIMEOUT) { + onDisconnect?.(); + } retryCount++; const elapsed = Date.now() - lastAttemptTime; const minGap = Math.max(0, BASE_RETRY_DELAY_MS - elapsed); @@ -279,8 +288,8 @@ export async function connectStream( if (abortController.signal.aborted) { break; } - // Ping timeouts report failure through onDisconnect below, not an abort error. - if (!connectionAbort.signal.aborted) { + // A heartbeat timeout renews the transport; it is not service failure evidence. + if (connectionAbort.signal.reason !== PING_TIMEOUT) { reportError(error); } if (error instanceof TokenResolutionError && !initialDataReceived) { @@ -291,10 +300,10 @@ export async function connectStream( // Ping timeout aborts only the per-connection controller; this is // an expected reconnect, not a real error. Stay silent on retryable // failures too — the error is only logged once retries are exhausted. - if (!connectionAbort.signal.aborted) { + if (connectionAbort.signal.reason !== PING_TIMEOUT) { lastError = error; + onDisconnect?.(); } - onDisconnect?.(); retryCount++; const elapsed = Date.now() - lastAttemptTime; const minGap = Math.max(0, BASE_RETRY_DELAY_MS - elapsed); diff --git a/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts b/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts index fe9eddd41..9ca533168 100644 --- a/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts +++ b/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts @@ -73,6 +73,9 @@ function stream() { close() { controller.close(); }, + fail(error: Error) { + controller.error(error); + }, }; } @@ -100,6 +103,7 @@ function client(options: Parameters[1] = {}) { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(now); + vi.spyOn(Math, 'random').mockReturnValue(0); vi.stubEnv('VERCEL', '0'); vi.mocked(readBundledDefinitions).mockResolvedValue({ definitions: null, @@ -198,6 +202,204 @@ it('tolerates a delayed stream ping without starting an early HTTP refresh', asy expect(observed).toEqual({ requests: 0, completed: 1 }); }); +it.each([ + 'reader cancellation', + 'transport abort error', +] as const)('reconnects an overdue stream internally after suspension: %s', async (abortPath) => { + const first = stream(); + const second = stream(); + streamFetch + .mockImplementationOnce(async (_input, init) => { + if (abortPath === 'transport abort error') { + init?.signal?.addEventListener('abort', () => { + first.fail(new Error('transport aborted')); + }); + } + return first.response; + }) + .mockResolvedValueOnce(second.response); + const instance = client({ staleIfError: 0 }); + const initial = instance.evaluate('feature'); + first.push({ type: 'datafile', data: data(2) }); + await initial; + const snapshot = await instance.getDatafile(); + + // Wall-clock age advances without any source messages while suspended. + vi.setSystemTime(now + 600_000); + await vi.advanceTimersByTimeAsync(90_001); + expect(streamFetch).toHaveBeenCalledTimes(2); + expect(streamFetch.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + expect(streamFetch.mock.calls[1]?.[1]?.signal?.aborted).toBe(false); + expect( + new Headers(streamFetch.mock.calls[1]?.[1]?.headers).get('X-Revision'), + ).toBe('2'); + expect(await instance.getDatafile()).toMatchObject({ + fetchedAt: snapshot.fetchedAt, + metrics: { mode: 'streaming', cacheStatus: 'STALE' }, + }); + await vi.advanceTimersByTimeAsync(30_000); + expect(dataFetch).not.toHaveBeenCalled(); + + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const settled = vi.fn(); + const reading = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(settled).not.toHaveBeenCalled(); + second.push({ + type: 'primed', + revision: 2, + projectId: 'prj_review', + environment: 'production', + }); + expect(await reading).toMatchObject({ + value: true, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + }); + expect(dataFetch.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + expect((await instance.getDatafile()).fetchedAt).toBe(snapshot.fetchedAt); + pending.resolve(Response.json(data(1, false))); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).configUpdatedAt).toBe(2); +}); + +it('keeps a watchdog on silent replacement streams without starting polling', async () => { + const first = stream(); + streamFetch + .mockResolvedValueOnce(first.response) + .mockImplementation(async () => stream().response); + const instance = client({ staleIfError: 0 }); + const initial = instance.evaluate('feature'); + first.push({ type: 'datafile', data: data(2) }); + await initial; + await vi.advanceTimersByTimeAsync(180_002); + // The second connection times out too; its next retry has one second of backoff. + await vi.advanceTimersByTimeAsync(1_000); + expect(streamFetch).toHaveBeenCalledTimes(3); + expect(streamFetch.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + expect(dataFetch).not.toHaveBeenCalled(); + expect(await instance.getDatafile()).toMatchObject({ + fetchedAt: now, + metrics: { mode: 'streaming', cacheStatus: 'STALE' }, + }); +}); + +it.each([ + 503, 401, +])('falls back to polling when the replacement stream returns %i', async (status) => { + const first = stream(); + streamFetch + .mockResolvedValueOnce(first.response) + .mockResolvedValueOnce(new Response(null, { status })) + .mockImplementation(async () => stream().response); + const instance = client(); + const initial = instance.evaluate('feature'); + first.push({ type: 'datafile', data: data(2) }); + await initial; + await vi.advanceTimersByTimeAsync(90_001); + expect((await instance.getDatafile()).metrics.mode).toBe('polling'); + dataFetch.mockResolvedValueOnce(Response.json(data(3, false))); + await vi.advanceTimersByTimeAsync(30_000); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); +}); + +it('falls back to polling if silent reconnects exhaust the stream retry budget', async () => { + const first = stream(); + streamFetch + .mockResolvedValueOnce(first.response) + .mockImplementation(async () => stream().response); + const instance = client(); + const initial = instance.evaluate('feature'); + first.push({ type: 'datafile', data: data(2) }); + await initial; + await vi.advanceTimersByTimeAsync(3_000_000); + expect(streamFetch).toHaveBeenCalledTimes(16); + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Max retry count exceeded', + expect.objectContaining({ message: 'stream: ping timeout' }), + ); + errorSpy.mockClear(); + expect((await instance.getDatafile()).metrics.mode).toBe('polling'); + const previousPolls = dataFetch.mock.calls.length; + await vi.advanceTimersByTimeAsync(30_000); + expect(dataFetch).toHaveBeenCalledTimes(previousPolls + 1); + expect(streamFetch).toHaveBeenCalledTimes(16); +}); + +it('does not reconnect or start polling when shutdown races a ping timeout', async () => { + const first = stream(); + streamFetch.mockResolvedValueOnce(first.response); + const instance = client(); + const initial = instance.evaluate('feature'); + first.push({ type: 'datafile', data: data(2) }); + await initial; + // Trigger cancellation, then shut down before its asynchronous retry runs. + vi.advanceTimersByTime(90_000); + await instance.shutdown(); + clients.delete(instance); + await vi.advanceTimersByTimeAsync(90_000); + expect(streamFetch).toHaveBeenCalledTimes(1); + expect(dataFetch).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); +}); + +it('resumes polling after suspension and shares the pending poll with expired reads', async () => { + const instance = client({ stream: false, staleIfError: 0 }); + await instance.initialize(); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + vi.setSystemTime(now + 600_000); + await vi.advanceTimersByTimeAsync(30_000); + const settled = vi.fn(); + const reading = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(dataFetch).toHaveBeenCalledTimes(2); + expect(settled).not.toHaveBeenCalled(); + pending.resolve(Response.json(data(2, false))); + expect(await reading).toMatchObject({ + value: false, + metrics: { mode: 'polling', cacheStatus: 'MISS' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(2); + expect(streamFetch).not.toHaveBeenCalled(); +}); + +it('continues scheduled polling after an in-flight fetch times out across suspension', async () => { + const instance = client({ stream: false, staleIfError: 0 }); + await instance.initialize(); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + await vi.advanceTimersByTimeAsync(30_000); + vi.setSystemTime(now + 600_000); + await vi.advanceTimersByTimeAsync(10_000); + expect(dataFetch.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + await expect(instance.getDatafile()).rejects.toThrow( + '@vercel/flags-core: Datafile fetch deadline exceeded', + ); + dataFetch.mockResolvedValueOnce(Response.json(data(2, false))); + await vi.advanceTimersByTimeAsync(20_000); + expect(dataFetch).toHaveBeenCalledTimes(3); + pending.resolve(new Response(null, { status: 401 })); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + expect((await instance.getDatafile()).configUpdatedAt).toBe(2); + expect(streamFetch).not.toHaveBeenCalled(); +}); + it('does not invalidate a healthy stream when a retired header fetch fails late', async () => { const pending = deferred(); dataFetch.mockReturnValueOnce(pending.promise); diff --git a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts index 0e5cd8722..82bc9bd58 100644 --- a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts @@ -580,19 +580,15 @@ describe('stream stale-if-error through the public API', () => { expectRequests(['0', '1']); }); - it('starts a new SIE allowance when a recovered stream times out again', async () => { - const { instance } = await start({ staleIfError: 0.1 }); + it('starts a new SIE allowance when a recovered stream closes again', async () => { + const { instance, stream } = await start({ staleIfError: 0.1 }); const reconnect = mockStream(); const third = mockStream(); streamFetch .mockResolvedValueOnce(reconnect.response) .mockResolvedValueOnce(third.response); - await vi.advanceTimersByTimeAsync(89_999); - expect((await instance.evaluate('flagA')).value).toBe(true); - expectRequests(['0']); - await vi.advanceTimersByTimeAsync(2); - expectRequests(['0', '1']); - await vi.advanceTimersByTimeAsync(99); + stream.close(); + await vi.advanceTimersByTimeAsync(100); expect((await instance.evaluate('flagA')).value).toBe(true); await vi.advanceTimersByTimeAsync(1); const failure = await instance @@ -602,12 +598,11 @@ describe('stream stale-if-error through the public API', () => { expect(failure).toMatchObject({ message: 'stream: disconnected' }); await expectExpired(instance, failure as Error); + await vi.advanceTimersByTimeAsync(899); reconnect.push(primed()); - reconnect.push({ type: 'ping' }); await vi.advanceTimersByTimeAsync(0); expect((await instance.evaluate('flagA')).value).toBe(true); - await vi.advanceTimersByTimeAsync(90_000); - expect((await instance.evaluate('flagA')).value).toBe(true); + reconnect.close(); await vi.advanceTimersByTimeAsync(100); expect((await instance.evaluate('flagA')).value).toBe(true); await vi.advanceTimersByTimeAsync(1); @@ -618,6 +613,7 @@ describe('stream stale-if-error through the public API', () => { expect(second).toMatchObject({ message: 'stream: disconnected' }); expect(second).not.toBe(failure); await expectExpired(instance, second as Error); + await vi.advanceTimersByTimeAsync(899); expectRequests(['0', '1', '1']); }); From 4fb7f9b961c8659df4373e332072aa60d5e4a65e Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 1 Oct 2026 14:20:01 +0200 Subject: [PATCH 34/41] fix(flags-core): unify project-aware reads and immediate stream fallback --- .changeset/header-driven-vercel-mode.md | 6 +- packages/vercel-flags-core/CLAUDE.md | 19 +- packages/vercel-flags-core/README.md | 31 +- .../vercel-flags-core/src/black-box.test.ts | 70 +-- .../src/controller/header-source.ts | 12 +- .../vercel-flags-core/src/controller/index.ts | 93 ++-- .../src/create-raw-client.ts | 4 +- .../src/source-lifecycle.black-box.test.ts | 18 +- .../src/stale-if-error.test.ts | 8 +- .../src/stream-stale-if-error.test.ts | 21 +- .../src/unified-reads.black-box.test.ts | 406 ++++++++++++++++++ .../src/vercel-mode.black-box.test.ts | 159 ++++--- 12 files changed, 655 insertions(+), 192 deletions(-) create mode 100644 packages/vercel-flags-core/src/unified-reads.black-box.test.ts diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index 82f373132..cc7b93d89 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -4,10 +4,10 @@ Add a header-driven `vercel` client mode, enabled by default when `VERCEL=1`. Initialization loads provided/bundled definitions; request versions trigger refreshes and an empty cache fetches on its first read. Explicit offline/build behavior is preserved. -An evaluation with a missing or empty version header permanently starts streaming when enabled, otherwise polling. Concurrent reads share startup and pending HTTP refreshes. Accepted stream updates and valid confirmations cancel superseded refreshes; waiting reads use the confirmed cache, and late responses cannot change cache or authorization state. Initialization and snapshot reads do not trigger this switch. +Evaluations and `getDatafile()` require a valid positive version header for their own project. Missing, empty, malformed, or unrelated project entries permanently start streaming when enabled, otherwise polling; multiple clients select their sources independently. Concurrent reads share startup and pending HTTP refreshes. Accepted stream updates and valid confirmations cancel superseded refreshes; waiting reads use the confirmed cache, and late responses cannot change cache or authorization state. `getDatafile()` shares lazy initialization and the evaluation resolution path, including header assessment, SWR, blocking refresh, stale-if-error, and source fallback. Initialization alone does not trigger this switch. A cold shared fetch discovers project identity before accepting header evidence; failed discovery starts source fallback. -The controller supplies a source freshness-status callback to the cache and configures one shared fetch callback. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Streaming becomes stale after 60 seconds and expires after 90 seconds. Polling becomes stale after its interval plus the 10-second fetch deadline and expires after two intervals plus that deadline (40/70 seconds by default). Stale evaluations refresh in the background; expired evaluations block on the shared refresh. Stream pings reset age and clear failures. Polling initialization honors its configured timeout while preserving the pending poll and interval; cached fallback does not renew cache age or stale-if-error. +The controller supplies a source freshness-status callback to the cache and configures one shared fetch callback. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Streaming becomes stale after 60 seconds and expires after 90 seconds. Polling becomes stale after its interval plus the 10-second fetch deadline and expires after two intervals plus that deadline (40/70 seconds by default). Stale reads refresh in the background; expired reads block on the shared refresh. Stream pings reset age and clear failures. Polling initialization honors its configured timeout while preserving the pending poll and interval; cached fallback does not renew cache age or stale-if-error. -Ping timeouts reconnect the stream internally, allowing suspended runtimes to resume without starting polling. Reconnection alone does not renew cache freshness. Connection errors, server closure, and exhausted retries still trigger the existing polling fallback. +Ping timeouts reconnect the stream internally, allowing suspended runtimes to resume without starting polling. Reconnection alone does not renew cache freshness. Connection errors, server closure, and exhausted retries trigger an immediate background poll followed by interval polling. Reads and polling share pending HTTP refreshes; stream recovery stops polling. Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. `staleWhileRevalidate` controls header-driven refreshes; stream/poll freshness follows their update schedules. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index e05a1950f..aae879adf 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -137,16 +137,20 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu latest accepted fetch or valid confirmation; unknown/expired cache age blocks for refresh. - Every returned entry passes through `DatafileCache.read()`. Refresh errors use its `staleIfError` allowance; expiry forces blocking recovery on the next newer-header read. -- Evaluations without a version header (including an empty header) permanently - start streaming if enabled, otherwise polling, using the existing startup timeouts. +- Reads without a valid positive version for this client’s project (missing, empty, + malformed, or unrelated headers) permanently start streaming if enabled, otherwise + polling, using the existing startup timeouts. Clients select independently. A cold + cache with a nonempty header first discovers project identity via a shared HTTP fetch; + failed discovery also uses the stream/poll fallback. Concurrent new reads share source startup and pending HTTP refreshes. Accepted stream updates and valid confirmations cancel superseded HTTP work; waiting reads use the confirmed cache, and late responses cannot change failure or authorization state. `resolveData()` checks header availability and uses `resolveDataWithFallbacks()` to start the configured source. Handover retains cached data before considering seeds. -- Present malformed/unrelated headers use cached data without fetching, subject to stale-if-error. -- `getDatafile()` remains a snapshot read: it enforces the same failure policy but does - not inspect request headers. Disabling both stream and polling selects offline mode. +- `getDatafile()` shares lazy initialization and `resolveData()` with evaluations, including + header assessment, SWR, blocking refresh, stale-if-error, and source fallback. + It only adds response construction and metrics, without evaluation telemetry. + Disabling both stream and polling selects offline mode. **Other runtime** (default outside Vercel, or `vercel: false`): 1. **Stream** - Real-time updates via NDJSON streaming, wait up to `initTimeoutMs` @@ -161,7 +165,8 @@ Key behaviors: - For offline mode with existing data, `initialize()` returns immediately - **Never stream AND poll simultaneously** - If stream reconnects while polling → stop polling -- If stream disconnects → start polling (if enabled) +- If stream disconnects → start an immediate background poll (if enabled), then interval polling. + Ping timeouts reconnect quietly without starting polling. - Use `buildStep: true` to force static-only mode (e.g., serverless cold starts) - Use `buildStep: false` to force runtime mode (e.g., custom build environments) @@ -287,7 +292,7 @@ When updating tests for new behavior, preserve the strength of existing assertio - Default `initTimeoutMs`: 3000ms (3s) - Datafile fetches use three total attempts with 100ms and 200ms backoff for network, token, body parsing, and transient HTTP failures (408, 429, and 5xx). Other HTTP errors fail immediately. After exhausted retries, polling emits an error event and waits for the next interval. - Stops automatically when stream reconnects -- `PollingSource` shares the cache's HTTP refresh for initialization and scheduled polls. Cache confirmation cancels superseded refreshes for stream evidence; the controller clears them on shutdown. Stopping the poller suppresses errors from its pending work. +- `PollingSource` shares the cache's HTTP refresh for initialization, immediate fallback, and scheduled polls. Cache confirmation cancels superseded refreshes for stream evidence; the controller clears them on shutdown. Stopping the poller suppresses errors from its pending work. - Initialization waits for the first poll up to `initTimeoutMs`. A timeout permits cached fallback without renewing cache age or failure allowance; the pending poll and recurring interval continue. - After runtime suspension, delayed intervals resume polling without changing sources. A request pending across suspension can hit its fetch deadline; the interval continues and a later successful poll clears the failure. - `fetchDatafile` owns a ten-second deadline covering token resolution, all attempts and backoff, and body parsing. It settles on timeout or cancellation even when a transport ignores its signal, and preserves the external abort reason. diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index 04683621b..dd5994930 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -37,14 +37,17 @@ Outside Vercel, pass an SDK key explicitly: `createClient(process.env.FLAGS)`. When `VERCEL=1`, the client defaults to `vercel: true`. Initialization loads provided or bundled definitions without starting a stream or polling. Request version headers -indicate when cached definitions need refreshing. If an evaluation has no version -header (or an empty one), the client permanently switches to streaming when enabled, -otherwise polling. Concurrent new evaluations share that startup and later headers do +indicate when cached definitions need refreshing. Header mode requires a valid positive +version for the client’s own `projectId`. Missing, empty, malformed, or unrelated entries +permanently switch that client to streaming when enabled, otherwise polling. Clients +with different projects select their sources independently within the same request. +Concurrent reads share that startup and later headers do not switch the client back. Pending HTTP refreshes remain shared until the stream delivers current data or confirms the cached version. That confirmation cancels the superseded refresh, and waiting reads use the confirmed cache; late responses cannot -change cache or authorization state. A present but malformed or unrelated header keeps the -existing cached-read behavior, fetching only when the cache is empty. +change cache or authorization state. With an empty cache and a nonempty header, the first +shared fetch discovers the client’s project before checking its header entry; if discovery +fails or the entry is unavailable, the client starts the stream/poll fallback. ```ts const client = createClient(process.env.FLAGS!, { @@ -62,8 +65,10 @@ preserve their original `fetchedAt`; unknown or expired cache age requires a blo refresh when a newer request version arrives. Refresh failures use `staleIfError`. A newer-header read attempts blocking recovery after that failure allowance expires. -`getDatafile()` remains a snapshot read: it applies stale-if-error but does not inspect -headers. Use `vercel: false` to select the existing stream/poll behavior. Disabling both +`getDatafile()` uses the same lazy initialization and resolution path as evaluations, +including header checks, background revalidation, blocking refresh, stale-if-error, and +source fallback. Concurrent calls share HTTP refreshes across both APIs. +Use `vercel: false` to select the stream/poll behavior. Disabling both stream and polling still selects offline mode, and builds retain their existing loading. ## Cached reads after errors @@ -97,16 +102,18 @@ throws the first failure when no default is supplied. `bulkEvaluate()` returns an error result for each requested flag, with its default value when provided. `getDatafile()` follows the same allowance and throws after expiry. The entry is retained for recovery, including its revision for stream reconnection. A clean -stream close or ping timeout records `stream: disconnected` if no earlier failure -exists. `getFallbackDatafile()` remains an independent bundled-data export. +stream close records `stream: disconnected` if no earlier failure exists. Ping timeouts +reconnect quietly without recording a failure or starting polling, including after runtime +suspension. Genuine disconnections start an immediate background poll, sharing pending +read refreshes, then continue at the configured interval. Stream recovery stops polling. `getFallbackDatafile()` remains an independent bundled-data export. Streaming data becomes stale after 60 seconds and expires after 90 seconds, allowing -one missed 30-second ping before revalidation and matching the stream's disconnect +one missed 30-second ping before revalidation and matching the stream's ping timeout. Polling data becomes stale after its interval plus the 10-second fetch deadline, and expires after two intervals plus that deadline (40 and 70 seconds with the default 30-second interval). These windows are independent of `staleWhileRevalidate`. -Stale evaluations refresh in the background; expired evaluations wait for the shared -refresh. Refresh failures still follow `staleIfError`. +Stale evaluations and `getDatafile()` calls refresh in the background; expired reads +wait for the shared refresh. Refresh failures still follow `staleIfError`. Accepted updates and valid confirmations reset cache age without rewriting `fetchedAt`. Stream pings also reset age and clear any failure. Poll errors feed the shared failure diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 07a589850..b0d722641 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -231,7 +231,8 @@ describe('Controller (black-box)', () => { expect((await client.getDatafile()).configUpdatedAt).toBe( expectedVersion, ); - const readRefreshes = configUpdatedAt < 2 ? 1 : 0; + // Each API refreshes the still-expired seed after an older response. + const readRefreshes = configUpdatedAt < 2 ? 2 : 0; expect(fetchMock).toHaveBeenCalledTimes(1 + readRefreshes); expect(dataFetch).toHaveBeenCalledTimes( (source === 'poll' ? 1 : 0) + readRefreshes, @@ -1044,7 +1045,7 @@ describe('Controller (black-box)', () => { fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); - if (url.includes('/v1/stream')) { + if (url.includes('/v1/stream') || url.includes('/v1/datafile')) { return Promise.resolve(new Response(null, { status: 401 })); } if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); @@ -1065,11 +1066,13 @@ describe('Controller (black-box)', () => { expect(result.value).toBe(true); expect(result.metrics?.source).toBe('embedded'); + expect(errorSpy).not.toHaveBeenCalled(); errorSpy.mockRestore(); - // Only one stream call — 401 does not trigger retries - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenLastCalledWith( + // One stream call and one immediate fallback poll; neither retries a 401 + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, 'https://flags.vercel.com/v1/stream', { headers: { @@ -1082,8 +1085,9 @@ describe('Controller (black-box)', () => { // Advance time to allow any potential retries (should not happen) await vi.advanceTimersByTimeAsync(5_000); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenLastCalledWith( + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, 'https://flags.vercel.com/v1/stream', { headers: { ...streamRequestHeaders, 'X-Revision': '1' }, @@ -1093,8 +1097,8 @@ describe('Controller (black-box)', () => { await client.shutdown(); await vi.advanceTimersByTimeAsync(0); - // still only one call, no ingest calls - expect(fetchMock).toHaveBeenCalledTimes(1); + // Still only the stream and immediate poll, with no ingest calls + expect(fetchMock).toHaveBeenCalledTimes(2); }); it('should use custom initTimeoutMs value', async () => { @@ -1470,7 +1474,7 @@ describe('Controller (black-box)', () => { if (url.includes('/v1/datafile')) { pollCount++; return Promise.resolve( - Response.json(makeBundled({ projectId: 'polled' })), + Response.json(makeBundled({ projectId: 'bundled' })), ); } if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); @@ -1494,6 +1498,9 @@ describe('Controller (black-box)', () => { expect(result.metrics?.source).toBe('embedded'); expect(pollCount).toBe(1); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ); warnSpy.mockRestore(); expect(fetchMock).toHaveBeenCalledTimes(2); @@ -1556,7 +1563,7 @@ describe('Controller (black-box)', () => { if (url.includes('/v1/datafile')) { pollCount++; return Promise.resolve( - Response.json(makeBundled({ projectId: 'polled' })), + Response.json(makeBundled({ projectId: 'bundled' })), ); } if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); @@ -1581,9 +1588,13 @@ describe('Controller (black-box)', () => { const result = await client.evaluate('flagA', undefined, undefined); expect(result.metrics?.source).toBe('embedded'); - // No polling should have started - expect(pollCount).toBe(0); + // Fallback starts one immediate poll. + expect(pollCount).toBe(1); + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ); errorSpy.mockRestore(); warnSpy.mockRestore(); @@ -1975,10 +1986,16 @@ describe('Controller (black-box)', () => { // Stream retries with backoff; advance timers so the init timeout fires const initPromise = client.initialize(); - await vi.advanceTimersByTimeAsync(5100); + await vi.advanceTimersByTimeAsync(4_999); + expect(pollCount).toBe(0); + await vi.advanceTimersByTimeAsync(101); await initPromise; - expect(pollCount).toBe(0); + expect(pollCount).toBe(1); + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ); await client.shutdown(); errorSpy.mockRestore(); @@ -2119,12 +2136,11 @@ describe('Controller (black-box)', () => { await vi.advanceTimersByTimeAsync(0); // Renew the zombie transport without degrading the selected source. - const snapshot = await client.getDatafile(); - expect(snapshot.metrics).toMatchObject({ - mode: 'streaming', - connectionState: 'connected', - cacheStatus: 'STALE', - }); + expect( + fetchMock.mock.calls.filter(([url]) => + String(url).endsWith('/v1/datafile'), + ), + ).toHaveLength(0); expect(streamCount).toBe(2); streams[1]!.push({ type: 'datafile', data: datafile }); @@ -2137,6 +2153,7 @@ describe('Controller (black-box)', () => { cacheStatus: 'HIT', }); + expect(errorSpy).not.toHaveBeenCalled(); await client.shutdown(); expect(errorSpy).not.toHaveBeenCalled(); errorSpy.mockRestore(); @@ -2543,7 +2560,7 @@ describe('Controller (black-box)', () => { const result = await client.getDatafile(); expect(result.metrics.source).toBe('embedded'); - expect(result.metrics.cacheStatus).toBe('MISS'); + expect(result.metrics.cacheStatus).toBe('STALE'); expect(result.metrics.connectionState).toBe('disconnected'); await client.shutdown(); @@ -2574,7 +2591,7 @@ describe('Controller (black-box)', () => { const result = await client.getDatafile(); expect(result.metrics.source).toBe('remote'); - expect(result.metrics.cacheStatus).toBe('MISS'); + expect(result.metrics.cacheStatus).toBe('STALE'); await client.shutdown(); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -2611,7 +2628,8 @@ describe('Controller (black-box)', () => { const rejection = expect(client.getDatafile()).rejects.toThrow( '@vercel/flags-core: No flag definitions available', ); - await vi.advanceTimersByTimeAsync(300); + // Lazy initialization and the read use the same fallback sequence as evaluate. + await vi.advanceTimersByTimeAsync(600); await rejection; await client.shutdown(); @@ -2665,7 +2683,7 @@ describe('Controller (black-box)', () => { const result = await client.getDatafile(); expect(result.metrics.source).toBe('embedded'); - expect(result.metrics.cacheStatus).toBe('MISS'); + expect(result.metrics.cacheStatus).toBe('HIT'); await client.shutdown(); }); @@ -2684,7 +2702,7 @@ describe('Controller (black-box)', () => { const result1 = await client.getDatafile(); expect(result1.metrics).toEqual({ - cacheStatus: 'MISS', + cacheStatus: 'STALE', connectionState: 'disconnected', mode: 'offline', readMs: 0, diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 2d2212694..1b9ff8f26 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -16,8 +16,16 @@ export class HeaderSource { ); } - isAvailable(): boolean { - return this.isEnabled() && Boolean(this.getVersionHeader()); + hasHeader(): boolean { + return Boolean(this.getVersionHeader()); + } + + isAvailable(projectId: string | undefined): boolean { + return ( + this.isEnabled() && + projectId !== undefined && + this.getUpdatedAtHeader(projectId, this.getVersionHeader()) !== undefined + ); } /** Capture the header now so a shared fetch cannot switch the request being assessed. */ diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index c959519b9..5e2709d0e 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -11,11 +11,7 @@ import type { TrackEvaluationOptions } from '../utils/usage/flags-evaluation'; import { UsageTracker } from '../utils/usage-tracker'; import { unauthorizedMessage } from './auth'; import { BundledSource } from './bundled-source'; -import { - type CacheAssessment, - type CacheReadPolicy, - DatafileCache, -} from './datafile-cache'; +import { type CacheReadPolicy, DatafileCache } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { HeaderSource } from './header-source'; import { @@ -85,7 +81,7 @@ type RuntimeSource = 'header' | 'stream' | 'polling'; * **Runtime — Vercel mode** (vercel enabled, with stream or polling enabled): * - Loads provided/bundled data before selecting the mode; no startup network * - HeaderSource checks request versions and refreshes when needed - * - An evaluation without a version header permanently starts stream/poll + * - A read without a valid project version header permanently starts stream/poll * - Cache applies version acceptance and stale-if-error to all served data * * **Runtime — offline mode** (neither stream nor polling): @@ -388,46 +384,14 @@ export class Controller implements ControllerInterface { } /** - * Returns the datafile with metrics. - * Uses in-memory data if available, otherwise falls back to bundled, - * then to a one-time fetch if called without prior initialization. + * Resolves the datafile through the same freshness and source policy as reads. + * Builds the response without recording an evaluation read event. */ async getDatafile(): Promise { const startTime = Date.now(); this.isFirstGetData = false; - let result = this.cache.read(); - let cacheStatus: Metrics['cacheStatus']; - - if (this.options.buildStep) { - [result, cacheStatus] = await this.resolveDataForBuildStep(); - } else if (result) { - const status = this.assessSnapshot(); - - cacheStatus = status === 'fresh' ? 'HIT' : 'STALE'; - } else { - // Preserve snapshot loading without starting stream/poll initialization. - const bundled = await this.bundledSource.tryLoad(); - if (bundled) { - this.cache.seed(tagData({ ...bundled }, 'bundled')); - } else { - try { - const fetched = await fetchDatafile({ - host: this.options.host, - auth: this.options.auth, - fetch: this.options.fetch, - }); - this.cache.seed(tagFetchedData(fetched)); - } catch (error) { - this.noteUnauthorized(error); - throw this.noDefinitionsError( - '. Initialize the client or provide a datafile.', - ); - } - } - cacheStatus = 'MISS'; - result = this.cache.read()!; - } + const [result, cacheStatus] = await this.resolveData(); if (this.dataViewSource !== result) { const { _origin, ...rest } = result; @@ -479,7 +443,35 @@ export class Controller implements ControllerInterface { private async resolveRuntimeData(): Promise< [TaggedData, Metrics['cacheStatus']] > { - if (this.state === 'vercel' && !this.headerSource.isAvailable()) { + // A cold header read first discovers its own project through the shared fetch. + // Never infer ownership from another project's entry in the request header. + if ( + this.state === 'vercel' && + !this.cache.hasData && + this.headerSource.hasHeader() + ) { + try { + const result = await this.cache.resolve(this.cacheReadPolicy); + if ( + result && + (this.state !== 'vercel' || + this.headerSource.isAvailable(result[0].projectId)) + ) { + return result; + } + } catch (error) { + if (this.isShutdown) { + throw error; + } + // Without an identified project, headers cannot drive recovery. + // Continue through the same stream/poll fallback as a missing entry. + } + } + + if ( + this.state === 'vercel' && + !this.headerSource.isAvailable(this.cache.metadata?.projectId) + ) { await this.activateFallbackSource('header'); } else if (this.sourceStartup) { await this.sourceStartup; @@ -505,19 +497,6 @@ export class Controller implements ControllerInterface { return this.resolveStaticFallbackData(); } - /** - * Assesses a snapshot without consuming request-header freshness evidence. - * Header assessment belongs to resolveData(), where it can trigger refreshes. - */ - private assessSnapshot(): CacheAssessment['status'] { - const metadata = this.cache.metadata; - if (!metadata || this.state === 'vercel') { - return 'unknown'; - } - - return this.cacheReadPolicy.assess(metadata).status; - } - private get cacheReadPolicy(): CacheReadPolicy { if (this.state === 'vercel') { return { @@ -582,9 +561,11 @@ export class Controller implements ControllerInterface { ) { this.pollingSource.startInterval(); this.transition('polling'); - // Stream fallback keeps its interval schedule; primary polling starts now. + // Repair a disconnected stream immediately; reads share this background poll. if (after === 'header') { await this.initializePolling(); + } else { + void this.pollingSource.poll().catch(() => {}); } if (this.isShutdown) { throw new Error('@vercel/flags-core: Client is shut down'); diff --git a/packages/vercel-flags-core/src/create-raw-client.ts b/packages/vercel-flags-core/src/create-raw-client.ts index 7e9c301ac..b579e2a6f 100644 --- a/packages/vercel-flags-core/src/create-raw-client.ts +++ b/packages/vercel-flags-core/src/create-raw-client.ts @@ -144,9 +144,9 @@ export function createCreateRawClient(fns: { }, getDatafile: async () => { const instance = controllerInstanceMap.get(id); - if (instance?.initPromise) { + if (!instance?.initialized) { try { - await instance.initPromise; + await api.initialize(); } catch { // Initialization failed — let getDatafile handle its own fallbacks } diff --git a/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts b/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts index 9ca533168..39bac1027 100644 --- a/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts +++ b/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts @@ -233,16 +233,13 @@ it.each([ expect( new Headers(streamFetch.mock.calls[1]?.[1]?.headers).get('X-Revision'), ).toBe('2'); - expect(await instance.getDatafile()).toMatchObject({ - fetchedAt: snapshot.fetchedAt, - metrics: { mode: 'streaming', cacheStatus: 'STALE' }, - }); await vi.advanceTimersByTimeAsync(30_000); expect(dataFetch).not.toHaveBeenCalled(); const pending = deferred(); dataFetch.mockReturnValueOnce(pending.promise); const settled = vi.fn(); + const snapshotRead = instance.getDatafile(); const reading = instance.evaluate('feature').then((result) => { settled(); return result; @@ -260,6 +257,10 @@ it.each([ value: true, metrics: { mode: 'streaming', cacheStatus: 'HIT' }, }); + expect(await snapshotRead).toMatchObject({ + fetchedAt: snapshot.fetchedAt, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + }); expect(dataFetch.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); expect((await instance.getDatafile()).fetchedAt).toBe(snapshot.fetchedAt); pending.resolve(Response.json(data(1, false))); @@ -282,10 +283,12 @@ it('keeps a watchdog on silent replacement streams without starting polling', as expect(streamFetch).toHaveBeenCalledTimes(3); expect(streamFetch.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); expect(dataFetch).not.toHaveBeenCalled(); + dataFetch.mockResolvedValueOnce(Response.json(data(2))); expect(await instance.getDatafile()).toMatchObject({ fetchedAt: now, - metrics: { mode: 'streaming', cacheStatus: 'STALE' }, + metrics: { mode: 'streaming', cacheStatus: 'MISS' }, }); + expect(dataFetch).toHaveBeenCalledTimes(1); }); it.each([ @@ -301,10 +304,11 @@ it.each([ first.push({ type: 'datafile', data: data(2) }); await initial; await vi.advanceTimersByTimeAsync(90_001); + expect(dataFetch).toHaveBeenCalledTimes(1); expect((await instance.getDatafile()).metrics.mode).toBe('polling'); dataFetch.mockResolvedValueOnce(Response.json(data(3, false))); await vi.advanceTimersByTimeAsync(30_000); - expect(dataFetch).toHaveBeenCalledTimes(1); + expect(dataFetch).toHaveBeenCalledTimes(2); expect(await instance.evaluate('feature')).toMatchObject({ value: false, metrics: { mode: 'polling', cacheStatus: 'HIT' }, @@ -440,7 +444,7 @@ it('cancels pending polling work when a stream reconnects', async () => { const pending = deferred(); dataFetch.mockReturnValueOnce(pending.promise); first.close(); - await vi.advanceTimersByTimeAsync(30_000); + await vi.advanceTimersByTimeAsync(1_000); const signal = dataFetch.mock.calls[0]?.[1]?.signal; expect(dataFetch).toHaveBeenCalledTimes(1); second.push({ diff --git a/packages/vercel-flags-core/src/stale-if-error.test.ts b/packages/vercel-flags-core/src/stale-if-error.test.ts index d238ea0ae..c45a950eb 100644 --- a/packages/vercel-flags-core/src/stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stale-if-error.test.ts @@ -357,18 +357,20 @@ describe('polling stale-if-error through the public API', () => { staleIfError: 0, ...(seed === 'provided' ? { datafile: supplied } : {}), }); - const snapshot = await instance.getDatafile(); + const snapshotRead = expect(instance.getDatafile()).rejects.toBe(failure); const evaluation = instance.evaluate('flagA'); const evaluationOutcome = expect(evaluation).rejects.toBe(failure); await vi.advanceTimersByTimeAsync(301); await evaluationOutcome; await expect(instance.getDatafile()).rejects.toBe(failure); expect(Date.now()).toBe(301); + await snapshotRead; + await vi.advanceTimersByTimeAsync(30_000); + const snapshot = await instance.getDatafile(); expect(snapshot.definitions).toBe(supplied.definitions); expect(snapshot.metrics.source).toBe( seed === 'provided' ? 'in-memory' : 'embedded', ); - await vi.advanceTimersByTimeAsync(30_000); expect((await instance.evaluate('flagA')).value).toBe(true); expect((await instance.getDatafile()).definitions).toBe( snapshot.definitions, @@ -630,7 +632,7 @@ describe('polling stale-if-error through the public API', () => { await expect(instance.getDatafile()).rejects.toThrow( 'stream: unauthorized (401)', ); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenCalledTimes(4); expect(poll).not.toHaveBeenCalled(); }); diff --git a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts index 82bc9bd58..f07fde731 100644 --- a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts @@ -129,7 +129,8 @@ beforeEach(() => { fetchMock.mockReset().mockImplementation((input, init) => { if (String(input).endsWith('/v1/stream')) return streamFetch(input, init); if (String(input).endsWith('/v1/datafile')) { - return Promise.resolve(Response.json(data())); + // Keep fallback HTTP pending so only the tested stream evidence can recover. + return new Promise(() => {}); } return Promise.reject(new Error(`Unexpected fetch: ${String(input)}`)); }); @@ -193,21 +194,21 @@ describe('stream stale-if-error through the public API', () => { const { instance, stream } = await start(); const snapshot = await instance.getDatafile(); await vi.advanceTimersByTimeAsync(60_001); - for (const [index, override] of [ + for (const override of [ { revision: 6 }, { projectId: 'other' }, { environment: 'preview' }, - ].entries()) { + ]) { stream.push(primed(override)); await vi.advanceTimersByTimeAsync(0); expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe( - index === 0 ? 'STALE' : 'HIT', + 'STALE', ); expect(await instance.getDatafile()).toEqual({ ...snapshot, metrics: { ...snapshot.metrics, - cacheStatus: index === 0 ? 'STALE' : 'HIT', + cacheStatus: 'STALE', }, }); } @@ -419,7 +420,7 @@ describe('stream stale-if-error through the public API', () => { undefined, Infinity, ])('cannot confirm a retained nonnumeric or nonfinite revision %s', async (revision) => { - const supplied = data({ revision: revision as number }); + const supplied = data({ revision: revision as number, fetchedAt: 0 }); const stream = mockStream(); const reconnect = mockStream(); streamFetch @@ -539,12 +540,10 @@ describe('stream stale-if-error through the public API', () => { await initialization; expect(initialized).toHaveBeenCalledOnce(); expectInitTimeout(); - await vi.advanceTimersByTimeAsync(10_000); + await vi.advanceTimersByTimeAsync(9_999); expect((await instance.evaluate('flagA')).value).toBe(true); - // Finish the read-triggered refresh before introducing a stream failure. - // Otherwise its successful response can recover the cache after the failure. + // The immediate fallback poll is still pending; no response has confirmed recovery. expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); - await waitUntil.mock.calls[0]![0]; expect((await instance.getDatafile()).definitions).toBe( supplied.definitions, ); @@ -664,7 +663,7 @@ describe('stream stale-if-error through the public API', () => { await expectExpired(instance, failure as Error); expect(Date.now()).toBe(0); await vi.advanceTimersByTimeAsync(60_000); - expect(getVercelOidcToken).toHaveBeenCalledTimes(5); + expect(getVercelOidcToken).toHaveBeenCalledTimes(8); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/packages/vercel-flags-core/src/unified-reads.black-box.test.ts b/packages/vercel-flags-core/src/unified-reads.black-box.test.ts new file mode 100644 index 000000000..7382a5932 --- /dev/null +++ b/packages/vercel-flags-core/src/unified-reads.black-box.test.ts @@ -0,0 +1,406 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { + type BundledDefinitions, + createClient, + type FlagsClient, +} from './index.default'; +import { setRequestContext } from './test-utils'; +import { readBundledDefinitions } from './utils/read-bundled-definitions'; + +vi.mock('./utils/read-bundled-definitions', () => ({ + readBundledDefinitions: vi.fn(), +})); + +const now = 1_700_000_000_000; +const clients = new Set(); +const dataFetch = vi.fn(); +const streamFetch = vi.fn(); +let cleanupContext = () => {}; +let warnSpy: ReturnType; +let errorSpy: ReturnType; + +function data(projectId = 'prj_a', revision = 1): BundledDefinitions { + return { + projectId, + environment: 'production', + revision, + digest: String(revision), + configUpdatedAt: revision, + fetchedAt: now, + segments: {}, + definitions: { + feature: { + environments: { production: revision % 2 }, + variants: [false, true], + }, + }, + }; +} + +function context(value?: string) { + cleanupContext(); + cleanupContext = setRequestContext( + value === undefined ? {} : { 'x-vercel-flags-config-versions': value }, + ); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function stream() { + let controller!: ReadableStreamDefaultController; + const body = new ReadableStream({ + start(value) { + controller = value; + }, + }); + return { + response: new Response(body), + push(message: unknown) { + controller.enqueue( + new TextEncoder().encode(`${JSON.stringify(message)}\n`), + ); + }, + close() { + controller.close(); + }, + }; +} + +function client( + projectId = 'prj_a', + options: Parameters[1] = {}, +) { + const instance = createClient(`vf_server_${projectId}`, { + vercel: true, + buildStep: false, + disableMetrics: true, + datafile: data(projectId), + fetch: (input, init) => { + if (String(input).endsWith('/v1/datafile')) { + return dataFetch(input, init); + } + if (String(input).endsWith('/v1/stream')) { + return streamFetch(input, init); + } + return Promise.resolve(new Response()); + }, + ...options, + }); + clients.add(instance); + return instance; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + vi.spyOn(Math, 'random').mockReturnValue(0); + vi.mocked(readBundledDefinitions).mockResolvedValue({ + definitions: null, + state: 'missing-file', + }); + dataFetch.mockReset(); + streamFetch.mockReset(); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + context('flags_prj_a=1'); +}); + +afterEach(async () => { + try { + await Promise.all([...clients].map((instance) => instance.shutdown())); + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + } finally { + clients.clear(); + cleanupContext(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}); + +it.each([ + 'streaming', + 'polling', +] as const)('selects %s independently for the client missing from a shared request header', async (mode) => { + const connection = stream(); + streamFetch.mockResolvedValueOnce(connection.response); + dataFetch.mockResolvedValueOnce(Response.json(data('prj_b', 2))); + const first = client(); + const second = client('prj_b', { stream: mode === 'streaming' }); + const reading = second.getDatafile(); + connection.push({ type: 'datafile', data: data('prj_b', 2) }); + expect(await first.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'vercel', cacheStatus: 'HIT' }, + }); + expect(await reading).toMatchObject({ + projectId: 'prj_b', + revision: 2, + metrics: { mode, cacheStatus: 'HIT' }, + }); + expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 1 : 0); + const calls = + mode === 'streaming' ? streamFetch.mock.calls : dataFetch.mock.calls; + expect(new Headers(calls[0]?.[1]?.headers).get('Authorization')).toBe( + 'Bearer vf_server_prj_b', + ); + context('flags_prj_a=1;flags_prj_b=2'); + expect((await first.getDatafile()).metrics.mode).toBe('vercel'); + expect((await second.evaluate('feature')).metrics?.mode).toBe(mode); +}); + +it.each([ + 'flags_prj_a=1', + 'flags_prj_b=1', +])('discovers a cold client project before accepting header %s', async (header) => { + context(header); + dataFetch.mockResolvedValueOnce(Response.json(data())); + const connection = stream(); + streamFetch.mockResolvedValueOnce(connection.response); + const instance = client('prj_a', { datafile: undefined }); + const reading = instance.getDatafile(); + connection.push({ type: 'datafile', data: data() }); + expect(await reading).toMatchObject({ + projectId: 'prj_a', + metrics: { mode: header === 'flags_prj_a=1' ? 'vercel' : 'streaming' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(streamFetch).toHaveBeenCalledTimes(header === 'flags_prj_a=1' ? 0 : 1); +}); + +it('shares lazy initialization and returns header-aware HITs without fetching', async () => { + const instance = client(); + const [file, evaluation] = await Promise.all([ + instance.getDatafile(), + instance.evaluate('feature'), + ]); + expect(file).toMatchObject({ + revision: 1, + fetchedAt: now, + metrics: { mode: 'vercel', cacheStatus: 'HIT' }, + }); + expect(evaluation).toMatchObject({ + value: true, + metrics: { mode: 'vercel', cacheStatus: 'HIT' }, + }); + expect(dataFetch).not.toHaveBeenCalled(); + expect(streamFetch).not.toHaveBeenCalled(); +}); + +it('getDatafile returns SWR data and shares its background refresh with evaluations', async () => { + context('flags_prj_a=2'); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const backgrounds: Promise[] = []; + const instance = client('prj_a', { + waitUntil: (promise) => { + backgrounds.push(promise); + }, + }); + expect(await instance.getDatafile()).toMatchObject({ + revision: 1, + metrics: { cacheStatus: 'STALE' }, + }); + const refresh = backgrounds[0]; + expect(refresh).toBeInstanceOf(Promise); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'STALE' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(data('prj_a', 2))); + await refresh; + expect(await instance.getDatafile()).toMatchObject({ + revision: 2, + metrics: { cacheStatus: 'HIT' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); +}); + +it.each([ + 'getDatafile', + 'evaluate', +] as const)('shares one expired blocking refresh when %s starts first', async (method) => { + context('flags_prj_a=2'); + vi.setSystemTime(now + 10_001); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + const settled = vi.fn(); + const start = (which: typeof method) => + (which === 'getDatafile' + ? instance.getDatafile() + : instance.evaluate('feature') + ).then((result) => { + settled(); + return result; + }); + const first = start(method); + const second = start(method === 'getDatafile' ? 'evaluate' : 'getDatafile'); + await vi.advanceTimersByTimeAsync(0); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(settled).not.toHaveBeenCalled(); + pending.resolve(Response.json(data('prj_a', 2))); + for (const result of await Promise.all([first, second])) { + expect(result.metrics).toMatchObject({ + mode: 'vercel', + cacheStatus: 'MISS', + }); + } + expect(settled).toHaveBeenCalledTimes(2); + expect(dataFetch).toHaveBeenCalledTimes(1); +}); + +it('getDatafile enforces the first failure deadline and performs blocking recovery after expiry', async () => { + context('flags_prj_a=2'); + const instance = client('prj_a', { + staleWhileRevalidate: 0, + staleIfError: 1, + }); + dataFetch.mockResolvedValueOnce( + new Response(null, { status: 403, statusText: 'first failure' }), + ); + expect(await instance.getDatafile()).toMatchObject({ + revision: 1, + metrics: { cacheStatus: 'STALE' }, + }); + vi.setSystemTime(now + 1_001); + dataFetch.mockResolvedValueOnce( + new Response(null, { status: 403, statusText: 'second failure' }), + ); + await expect(instance.getDatafile()).rejects.toThrow( + 'Failed to fetch data: first failure', + ); + expect(dataFetch).toHaveBeenCalledTimes(2); + dataFetch.mockResolvedValueOnce(Response.json(data('prj_a', 2))); + expect(await instance.getDatafile()).toMatchObject({ + revision: 2, + metrics: { cacheStatus: 'MISS' }, + }); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe('HIT'); + expect(dataFetch).toHaveBeenCalledTimes(3); +}); + +it('a missing project header starts streaming and a real disconnect starts exactly one immediate shared poll', async () => { + context('flags_other=1'); + const first = stream(); + const second = stream(); + streamFetch + .mockResolvedValueOnce(first.response) + .mockResolvedValueOnce(second.response); + const instance = client('prj_a', { staleIfError: 0 }); + const reading = instance.getDatafile(); + first.push({ type: 'datafile', data: data() }); + expect((await reading).metrics.mode).toBe('streaming'); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + first.close(); + await vi.advanceTimersByTimeAsync(0); + expect(dataFetch).toHaveBeenCalledTimes(1); + const signal = dataFetch.mock.calls[0]?.[1]?.signal; + // Reads with an exhausted error allowance fail until the shared poll recovers. + await expect(instance.getDatafile()).rejects.toThrow('stream: disconnected'); + await expect(instance.evaluate('feature')).rejects.toThrow( + 'stream: disconnected', + ); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(data('prj_a', 2))); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.getDatafile()).toMatchObject({ + revision: 2, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + expect(signal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1_000); + second.push({ type: 'datafile', data: data('prj_a', 3) }); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.getDatafile()).toMatchObject({ + revision: 3, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + }); + await vi.advanceTimersByTimeAsync(30_000); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(streamFetch).toHaveBeenCalledTimes(2); +}); + +it('falls through to streaming when cold project discovery fails', async () => { + context('flags_other=1'); + dataFetch.mockResolvedValueOnce(new Response(null, { status: 403 })); + const connection = stream(); + streamFetch.mockResolvedValueOnce(connection.response); + const instance = client('prj_a', { datafile: undefined, staleIfError: 0 }); + const reading = instance.getDatafile(); + connection.push({ type: 'datafile', data: data() }); + expect(await reading).toMatchObject({ + projectId: 'prj_a', + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(streamFetch).toHaveBeenCalledTimes(1); +}); + +it('starts an immediate poll when a missing project header falls back to a failing stream', async () => { + context('flags_other=1'); + streamFetch.mockResolvedValueOnce(new Response(null, { status: 401 })); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + const instance = client(); + expect(await instance.getDatafile()).toMatchObject({ + revision: 1, + metrics: { mode: 'polling', cacheStatus: 'STALE' }, + }); + expect(Date.now()).toBe(now); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(streamFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(data('prj_a', 2))); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.getDatafile()).toMatchObject({ + revision: 2, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); +}); + +it('shares an existing read refresh with the immediate disconnect poll', async () => { + context(); + const connection = stream(); + const reconnect = stream(); + streamFetch + .mockResolvedValueOnce(connection.response) + .mockResolvedValueOnce(reconnect.response); + const instance = client(); + const reading = instance.getDatafile(); + connection.push({ type: 'datafile', data: data() }); + await reading; + await vi.advanceTimersByTimeAsync(60_001); + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + expect((await instance.getDatafile()).metrics.cacheStatus).toBe('STALE'); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect(dataFetch).toHaveBeenCalledTimes(1); + connection.close(); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).metrics.mode).toBe('polling'); + expect(dataFetch).toHaveBeenCalledTimes(1); + pending.resolve(Response.json(data('prj_a', 2))); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.getDatafile()).toMatchObject({ + revision: 2, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + reconnect.push({ type: 'datafile', data: data('prj_a', 3) }); + await vi.advanceTimersByTimeAsync(30_000); + expect((await instance.getDatafile()).metrics.mode).toBe('streaming'); + expect(dataFetch).toHaveBeenCalledTimes(1); +}); diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 90a6977c1..831ba4a09 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -352,14 +352,15 @@ describe('Vercel mode (black-box)', () => { expect(streamFetch).not.toHaveBeenCalled(); }); - it('lets a cold header read fail while streaming starts independently', async () => { + it('shares stream startup after a concurrent cold header discovery fails', async () => { const pendingHeader = deferred(); dataFetch.mockReturnValueOnce(pendingHeader.promise); const instance = client({ datafile: undefined, staleIfError: 0 }); - const originalRead = instance.evaluate('feature'); - const rejected = expect(originalRead).rejects.toThrow( - 'Failed to fetch data: Unauthorized', - ); + const settled = vi.fn(); + const originalRead = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); await vi.advanceTimersByTimeAsync(0); const signal = dataFetch.mock.calls[0]?.[1]?.signal; @@ -372,13 +373,16 @@ describe('Vercel mode (black-box)', () => { pendingHeader.resolve( new Response(null, { status: 401, statusText: 'Unauthorized' }), ); - await rejected; + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 1, true) }); - expect(await switchingRead).toMatchObject({ - value: true, - metrics: { mode: 'streaming' }, - }); + for (const result of await Promise.all([originalRead, switchingRead])) { + expect(result).toMatchObject({ + value: true, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + }); + } expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); expect(dataFetch).toHaveBeenCalledTimes(1); expect(streamFetch).toHaveBeenCalledTimes(1); @@ -654,23 +658,28 @@ describe('Vercel mode (black-box)', () => { undefined, 0, 0.01, - ])('recovers on a later read when the cold-cache fetch fails with staleIfError=%s', async (staleIfError) => { + ])('recovers via streaming when cold project discovery fails with staleIfError=%s', async (staleIfError) => { + const stream = mockStream(); + streamFetch.mockResolvedValueOnce(stream.response); const instance = client({ datafile: undefined, staleIfError }); mockDatafileHttpFailure(); - const failure = expect(instance.evaluate('feature')).rejects.toThrow( - 'Failed to fetch data', - ); + const settled = vi.fn(); + const reading = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); await vi.advanceTimersByTimeAsync(300); - await failure; + expect(settled).not.toHaveBeenCalled(); expect(dataFetch).toHaveBeenCalledTimes(3); + expect(streamFetch).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(11); - mockDatafileResponse(TIMESTAMP, true); - - expect(await instance.evaluate('feature')).toMatchObject({ + stream.push({ type: 'datafile', data: datafile(TIMESTAMP, true) }); + expect(await reading).toMatchObject({ value: true, - metrics: { mode: 'vercel', cacheStatus: 'MISS' }, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, }); - expect(dataFetch).toHaveBeenCalledTimes(4); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); + expect(dataFetch).toHaveBeenCalledTimes(3); }); it.each([ @@ -728,12 +737,13 @@ describe('Vercel mode (black-box)', () => { origin === 'bundled' ? 1 : 0, ); expect(transport).not.toHaveBeenCalled(); + setVersion(TIMESTAMP); expect(await instance.getDatafile()).toEqual({ ...bundled, metrics: expect.objectContaining({ mode: 'vercel', source: origin === 'provided' ? 'in-memory' : 'embedded', - cacheStatus: 'STALE', + cacheStatus: 'HIT', }), }); setVersion(TIMESTAMP); @@ -754,19 +764,17 @@ describe('Vercel mode (black-box)', () => { ).toBe(true); }); - it('does not renew freshness from a matching header on a snapshot read', async () => { + it('renews freshness from a matching header on getDatafile without rewriting fetchedAt', async () => { const input = { ...datafile(), fetchedAt: TIMESTAMP }; const instance = client({ datafile: input }); - await instance.initialize(); vi.setSystemTime(TIMESTAMP + 11_000); - setVersion(TIMESTAMP); const snapshot = await instance.getDatafile(); expect(snapshot).toEqual({ ...input, metrics: { readMs: 0, source: 'in-memory', - cacheStatus: 'STALE', + cacheStatus: 'HIT', connectionState: 'disconnected', mode: 'vercel', }, @@ -777,30 +785,26 @@ describe('Vercel mode (black-box)', () => { setVersion(TIMESTAMP + 1); mockDatafileResponse(TIMESTAMP + 1, true); expect(await instance.evaluate('feature')).toMatchObject({ - value: true, - metrics: { cacheStatus: 'MISS' }, + value: false, + metrics: { cacheStatus: 'STALE' }, }); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); expect(dataFetch).toHaveBeenCalledTimes(1); }); - it('does not observe newer snapshot headers when assessing later matching evaluations', async () => { + it('shares getDatafile invalidation with later evaluation headers', async () => { const input = { ...datafile(), fetchedAt: TIMESTAMP }; const instance = client({ datafile: input }); - await instance.initialize(); vi.setSystemTime(TIMESTAMP + 9_000); setVersion(TIMESTAMP + 1); - const snapshot = await instance.getDatafile(); - expect(snapshot).toEqual({ + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + expect(await instance.getDatafile()).toMatchObject({ ...input, - metrics: { - readMs: 0, - source: 'in-memory', - cacheStatus: 'STALE', - connectionState: 'disconnected', - mode: 'vercel', - }, + metrics: { cacheStatus: 'STALE', mode: 'vercel' }, }); - expect(dataFetch).not.toHaveBeenCalled(); + expect(dataFetch).toHaveBeenCalledTimes(1); setVersion(TIMESTAMP); expect(await instance.evaluate('feature')).toMatchObject({ @@ -809,12 +813,18 @@ describe('Vercel mode (black-box)', () => { }); vi.setSystemTime(TIMESTAMP + 10_001); setVersion(TIMESTAMP + 1); - mockDatafileResponse(TIMESTAMP + 1, true); - expect(await instance.evaluate('feature')).toMatchObject({ - value: false, - metrics: { cacheStatus: 'STALE' }, + const settled = vi.fn(); + const blocking = instance.evaluate('feature').then((result) => { + settled(); + return result; }); await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); + pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); + expect(await blocking).toMatchObject({ + value: true, + metrics: { cacheStatus: 'MISS' }, + }); expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); expect(dataFetch).toHaveBeenCalledTimes(1); }); @@ -1175,6 +1185,7 @@ describe('Vercel mode (black-box)', () => { value: true, metrics: { cacheStatus: 'MISS' }, }); + setVersion(TIMESTAMP - 1); expect(await instance.getDatafile()).toEqual({ ...datafile(TIMESTAMP + 1, true), fetchedAt: TIMESTAMP, @@ -1249,6 +1260,7 @@ describe('Vercel mode (black-box)', () => { const later = TIMESTAMP + 365 * 24 * 60 * 60 * 1_000; vi.setSystemTime(later); const second = client({ datafile: JSON.parse(JSON.stringify(fetched)) }); + setVersion(TIMESTAMP - 1); expect((await second.getDatafile()).fetchedAt).toBe(TIMESTAMP); setVersion(TIMESTAMP + 1); @@ -1273,7 +1285,6 @@ describe('Vercel mode (black-box)', () => { it.each([ ['older', TIMESTAMP - 1], - ['malformed', 'invalid'], ['newer', TIMESTAMP + 1], ] as const)('does not renew freshness for %s headers', async (_kind, version) => { const instance = client(); @@ -1350,7 +1361,9 @@ describe('Vercel mode (black-box)', () => { 'STALE', ); await vi.advanceTimersByTimeAsync(0); + setVersion(TIMESTAMP - 1); expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); + setVersion(TIMESTAMP + 100_000); // The accepted response renews fetched freshness beyond the confirmation. vi.setSystemTime(TIMESTAMP + 29_000); @@ -1406,12 +1419,14 @@ describe('Vercel mode (black-box)', () => { 'STALE', ); await vi.advanceTimersByTimeAsync(0); + setVersion(TIMESTAMP - 1); expect(await instance.getDatafile()).toEqual({ ...datafile(), metrics: expect.any(Object), }); vi.setSystemTime(TIMESTAMP + 10_001); + setVersion(TIMESTAMP + 1); mockDatafileResponse(TIMESTAMP + 1, true); expect(await instance.evaluate('feature')).toMatchObject({ value: delta !== 0, @@ -1437,8 +1452,10 @@ describe('Vercel mode (black-box)', () => { '@vercel/flags-core: Revalidation failed:', expect.any(Error), ); + setVersion(TIMESTAMP - 1); expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); vi.setSystemTime(TIMESTAMP + 10_001); + setVersion(TIMESTAMP + 1); mockDatafileResponse(TIMESTAMP + 1, true); expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( 'STALE', @@ -1461,17 +1478,19 @@ describe('Vercel mode (black-box)', () => { vi.setSystemTime(TIMESTAMP + 10_001); setVersion(TIMESTAMP + 2); mockDatafileResponse(TIMESTAMP + 1 + delta, false); - // Both the blocking read and later snapshots use the cache version guard. + // Both APIs use the cache version guard; inspect with a satisfied header. expect(await instance.evaluate('feature')).toMatchObject({ value: true, metrics: { cacheStatus: 'MISS' }, }); + setVersion(TIMESTAMP - 1); expect(await instance.getDatafile()).toEqual({ ...datafile(TIMESTAMP + 1, true), fetchedAt: TIMESTAMP, metrics: expect.any(Object), }); + setVersion(TIMESTAMP + 2); mockDatafileResponse(TIMESTAMP + 2, true); expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( delta === 0 ? 'STALE' : 'MISS', @@ -1505,6 +1524,7 @@ describe('Vercel mode (black-box)', () => { }); it('uses the default unlimited stale-if-error allowance after blocking failures', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); setVersion(TIMESTAMP + 1); const instance = client(); dataFetch.mockRejectedValue(new Error('service unavailable')); @@ -1516,17 +1536,21 @@ describe('Vercel mode (black-box)', () => { metrics: { cacheStatus: 'STALE' }, }); vi.setSystemTime(TIMESTAMP + 365 * 24 * 60 * 60 * 1_000); - const secondFailure = instance.evaluate('feature'); + const secondFailure = instance.getDatafile(); await vi.advanceTimersByTimeAsync(300); expect(await secondFailure).toMatchObject({ - value: false, + configUpdatedAt: TIMESTAMP, metrics: { cacheStatus: 'STALE' }, }); - expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); expect(dataFetch).toHaveBeenCalledTimes(6); + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Revalidation failed:', + expect.objectContaining({ message: 'service unavailable' }), + ); }); - it('shares the first-error deadline with snapshots and recovers after expiry', async () => { + it('shares the first-error deadline across reads and recovers after expiry', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const instance = client({ staleIfError: 1 }); setVersion(TIMESTAMP + 1); const firstError = new Error('first failure'); @@ -1537,14 +1561,17 @@ describe('Vercel mode (black-box)', () => { vi.setSystemTime(TIMESTAMP + 1_000); rejectDatafileOnce(new Error('second failure')); - const secondFailure = instance.evaluate('feature'); + const secondFailure = instance.getDatafile(); await vi.advanceTimersByTimeAsync(300); - expect((await secondFailure).value).toBe(false); - expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); + expect((await secondFailure).configUpdatedAt).toBe(TIMESTAMP); + expect(errorSpy).toHaveBeenCalledExactlyOnceWith( + '@vercel/flags-core: Revalidation failed:', + expect.objectContaining({ message: 'second failure' }), + ); vi.setSystemTime(TIMESTAMP + 1_301); - // Older/malformed evidence must not reset the first-error deadline or fetch. - for (const version of ['invalid', TIMESTAMP - 1, TIMESTAMP]) { + // Older request evidence must not reset the first-error deadline or fetch. + for (const version of [TIMESTAMP - 1, TIMESTAMP]) { setVersion(version); await expect(instance.evaluate('feature')).rejects.toBe(firstError); await expect(instance.getDatafile()).rejects.toBe(firstError); @@ -1553,7 +1580,7 @@ describe('Vercel mode (black-box)', () => { setVersion(TIMESTAMP + 1); rejectDatafileOnce(new Error('third failure')); - const thirdFailure = expect(instance.evaluate('feature')).rejects.toBe( + const thirdFailure = expect(instance.getDatafile()).rejects.toBe( firstError, ); await vi.advanceTimersByTimeAsync(300); @@ -1584,18 +1611,20 @@ describe('Vercel mode (black-box)', () => { '@vercel/flags-core: Revalidation failed:', failure, ); + setVersion(TIMESTAMP - 1); await expect(instance.getDatafile()).rejects.toBe(failure); vi.setSystemTime(TIMESTAMP + 1); + setVersion(TIMESTAMP + 1); const pending = deferred(); dataFetch.mockReturnValueOnce(pending.promise); const settled = vi.fn(); - const read = instance.evaluate('feature').finally(settled); + const read = instance.getDatafile().finally(settled); const outcome = delta < 0 ? expect(read).rejects.toBe(failure) : expect(read).resolves.toMatchObject({ - value: delta > 0, + configUpdatedAt: TIMESTAMP + delta, metrics: { cacheStatus: 'MISS' }, }); await vi.advanceTimersByTimeAsync(0); @@ -1604,6 +1633,7 @@ describe('Vercel mode (black-box)', () => { pending.resolve(Response.json(datafile(TIMESTAMP + delta, true))); await outcome; + setVersion(TIMESTAMP - 1); if (delta < 0) { await expect(instance.getDatafile()).rejects.toBe(failure); return; @@ -1622,15 +1652,18 @@ describe('Vercel mode (black-box)', () => { `flags_${PROJECT_ID}=NaN`, `flags_${PROJECT_ID}=Infinity`, `flags_${PROJECT_ID}=`, - ])('ignores unusable header %s without starting sources', async (header) => { + ])('starts fallback polling for unusable project header %s', async (header) => { cleanupContext(); cleanupContext = setRequestContext({ [HEADER]: header }); - const instance = client(); - expect(await instance.evaluate('feature')).toMatchObject({ - value: false, - metrics: { cacheStatus: 'STALE', mode: 'vercel' }, + mockDatafileResponse(TIMESTAMP + 1, true); + const instance = client({ stream: false }); + expect(await instance.getDatafile()).toMatchObject({ + configUpdatedAt: TIMESTAMP + 1, + metrics: { cacheStatus: 'HIT', mode: 'polling' }, }); - expect(transport).not.toHaveBeenCalled(); + expect((await instance.evaluate('feature')).value).toBe(true); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(streamFetch).not.toHaveBeenCalled(); }); it('prefers the Vercel header and parses spaced project entries with a legacy cached version', async () => { From a1eafd8069fbbb9f311a1dbffb8aafff27e0ce0d Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 1 Oct 2026 14:25:13 +0200 Subject: [PATCH 35/41] test(vercel-adapter): cover active discovery client initialization --- packages/adapter-vercel/src/index.test.ts | 44 +++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/adapter-vercel/src/index.test.ts b/packages/adapter-vercel/src/index.test.ts index a7a49c465..974b7a558 100644 --- a/packages/adapter-vercel/src/index.test.ts +++ b/packages/adapter-vercel/src/index.test.ts @@ -222,6 +222,8 @@ describe('createVercelAdapter', () => { describe('when used with getProviderData', () => { let originalFlags: string | undefined; + const streamRequests = vi.fn(); + const datafileRequests = vi.fn(); beforeAll(() => { originalFlags = process.env.FLAGS; @@ -233,12 +235,40 @@ describe('when used with getProviderData', () => { }); beforeEach(() => { + vi.useFakeTimers(); + vi.stubEnv('CI', ''); + vi.stubEnv('NEXT_PHASE', ''); + streamRequests.mockClear(); + datafileRequests.mockClear(); resetDefaultFlagsClient(); resetDefaultVercelAdapter(); - // Mock the datafile endpoint for getDatafile + // Discovery inherits getDatafile's lazy source initialization. Keep the stream + // open so this fixture does not trigger disconnect recovery after its first update. server.use( + http.get('https://flags.vercel.com/v1/stream', () => { + streamRequests(); + return new HttpResponse( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + `${JSON.stringify({ + type: 'datafile', + data: { + projectId: 'prj_xxx', + definitions: {}, + segments: {}, + }, + })}\n`, + ), + ); + }, + }), + ); + }), http.get('https://flags.vercel.com/v1/datafile', () => { + datafileRequests(); return HttpResponse.json({ projectId: 'prj_xxx', definitions: {}, @@ -248,13 +278,23 @@ describe('when used with getProviderData', () => { ); }); + afterEach(async () => { + await flagsClient.shutdown(); + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + it('returns data', async () => { const testFlag = flag({ key: 'test-flag', adapter: vercelAdapter(), }); - const providerData = await getProviderData({ testFlag }); + const reading = getProviderData({ testFlag }); + await vi.advanceTimersByTimeAsync(0); + const providerData = await reading; + expect(streamRequests).toHaveBeenCalledTimes(1); + expect(datafileRequests).not.toHaveBeenCalled(); expect(providerData).toEqual({ definitions: { From 8ec369e3446c754b747be1ba6eba2cf98a8fb5ea Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 1 Oct 2026 15:13:05 +0200 Subject: [PATCH 36/41] refactor(flags-core): signal source fallback through cache assessment --- packages/vercel-flags-core/CLAUDE.md | 11 ++-- packages/vercel-flags-core/README.md | 7 ++- .../controller/datafile-cache-policy.test.ts | 52 +++++++++++++++- .../src/controller/datafile-cache.ts | 15 ++++- .../src/controller/header-source.ts | 16 +---- .../vercel-flags-core/src/controller/index.ts | 60 ++++++++----------- .../src/unified-reads.black-box.test.ts | 56 ++++++++++++++++- .../src/vercel-mode.black-box.test.ts | 31 +++++++--- 8 files changed, 180 insertions(+), 68 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index aae879adf..60ce36031 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -140,13 +140,16 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu - Reads without a valid positive version for this client’s project (missing, empty, malformed, or unrelated headers) permanently start streaming if enabled, otherwise polling, using the existing startup timeouts. Clients select independently. A cold - cache with a nonempty header first discovers project identity via a shared HTTP fetch; - failed discovery also uses the stream/poll fallback. + cache first loads definitions and discovers project identity via a shared HTTP fetch. + The next read checks source availability through the cache assessment; failed discovery + starts the stream/poll fallback immediately. Concurrent new reads share source startup and pending HTTP refreshes. Accepted stream updates and valid confirmations cancel superseded HTTP work; waiting reads use the confirmed cache, and late responses cannot change failure or authorization state. - `resolveData()` checks header availability and uses `resolveDataWithFallbacks()` - to start the configured source. Handover retains cached data before considering seeds. + An assessment with status `error` returns a STALE cache result and a source-error + indicator without starting or clearing a fetch-failure deadline. Unservable data is + omitted, allowing the controller to start fallback even after stale-if-error expires. + Handover retains cached data before considering seeds. - `getDatafile()` shares lazy initialization and `resolveData()` with evaluations, including header assessment, SWR, blocking refresh, stale-if-error, and source fallback. It only adds response construction and metrics, without evaluation telemetry. diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index dd5994930..d9f3b72f2 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -45,9 +45,10 @@ Concurrent reads share that startup and later headers do not switch the client back. Pending HTTP refreshes remain shared until the stream delivers current data or confirms the cached version. That confirmation cancels the superseded refresh, and waiting reads use the confirmed cache; late responses cannot -change cache or authorization state. With an empty cache and a nonempty header, the first -shared fetch discovers the client’s project before checking its header entry; if discovery -fails or the entry is unavailable, the client starts the stream/poll fallback. +change cache or authorization state. With an empty cache, the first read uses a shared +fetch to load definitions and discover the client’s project. The next read assesses +that project’s header entry and starts the stream/poll fallback if it is unavailable. +If the cold fetch fails, the client starts fallback immediately. ```ts const client = createClient(process.env.FLAGS!, { diff --git a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts index b4d674da6..b63f267ba 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts @@ -56,6 +56,54 @@ afterEach(() => { }); describe('cache read callbacks', () => { + it('reports a source error without fetching, confirming, or starting a failure deadline', async () => { + const fetch = vi.fn(); + const cache = new DatafileCache(fetch, 0); + const original = tagData({ ...data(), fetchedAt: 500 }, 'provided'); + cache.seed(original); + expect( + await cache.resolve({ assess: () => ({ status: 'error' }) }), + ).toEqual([original, 'STALE', true]); + expect(cache.read()).toBe(original); + expect(cache.ageMs).toBe(500); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('reports a source error after stale-if-error expires without serving retained data', async () => { + const fetch = vi.fn(); + const cache = new DatafileCache(fetch, 100); + const original = tagData({ ...data(), fetchedAt: 500 }, 'provided'); + cache.seed(original); + const failure = new Error('original outage'); + cache.fail(failure); + const policy = { assess: () => ({ status: 'error' as const }) }; + expect(await cache.resolve(policy)).toEqual([original, 'STALE', true]); + vi.setSystemTime(1_101); + expect(await cache.resolve(policy)).toEqual([undefined, 'STALE', true]); + expect(() => cache.read()).toThrow(failure); + expect(cache.ageMs).toBe(601); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('serves a shared cold fetch before returning the assessment error on the next read', async () => { + const fetch = vi.fn(async () => data()); + const cache = new DatafileCache(fetch, 0); + const assess = vi.fn(() => ({ status: 'error' as const })); + expect( + await Promise.all([cache.resolve({ assess }), cache.resolve({ assess })]), + ).toEqual([ + [cache.read(), 'MISS'], + [cache.read(), 'MISS'], + ]); + expect(await cache.resolve({ assess })).toEqual([ + cache.read(), + 'STALE', + true, + ]); + expect(assess).toHaveBeenCalledTimes(3); + expect(fetch).toHaveBeenCalledTimes(1); + }); + it.each([ 'fresh', 'unknown', @@ -316,10 +364,10 @@ describe('header freshness policy', () => { 'flags_prj_policy=0', 'flags_prj_policy=-1', 'flags_prj_policy=Infinity', - ])('treats missing or malformed header %s as unknown', (header) => { + ])('treats missing or malformed header %s as a source error', (header) => { const headerSource = source(); expect(assessment(headerSource, header)({ ...data(), ageMs: 0 })).toEqual({ - status: 'unknown', + status: 'error', }); }); diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 9b85761bb..67256c859 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -12,13 +12,16 @@ export type CacheMetadata = Confirmation & { ageMs: number }; export type Freshness = 'fresh' | 'stale' | 'expired' | 'unknown'; export type CacheAssessment = { - status: Freshness; + /** Error means the source cannot assess this entry; the controller must fall back. */ + status: Freshness | 'error'; /** Positive evidence that renews age and clears the current failure. */ confirmed?: boolean; }; export type CacheFetch = (signal: AbortSignal) => Promise; -type CacheResult = [TaggedData, Metrics['cacheStatus']]; +export type CacheResult = + | [TaggedData, Metrics['cacheStatus'], sourceError?: undefined] + | [TaggedData | undefined, 'STALE', sourceError: true]; const SOURCE_CONFIRMED = new Error( 'Refresh superseded by a source confirmation', ); @@ -195,6 +198,11 @@ export class DatafileCache { const metadata = this.metadata; if (metadata) { const { status, confirmed } = policy.assess(metadata); + if (status === 'error') { + // Source availability is not a fetch failure. Preserve the failure deadline + // and report the source error even when retained data can no longer be served. + return [this.canServe() ? this.read() : undefined, 'STALE', true]; + } // Apply recovery evidence before read() enforces the failure deadline. if (confirmed) this.confirm(); if (status === 'fresh' || status === 'unknown') { @@ -243,7 +251,8 @@ export class DatafileCache { return [stale, 'STALE']; } - // A cold fetch discovers the project; assess the original request's header. + // Record the original request's evidence once a cold fetch discovers metadata. + // The fetched data serves this read; source availability is checked on the next. if (!metadata && this.metadata) { const { confirmed } = policy.assess(this.metadata); if (confirmed) this.confirm(); diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index 1b9ff8f26..f0bac2715 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -16,25 +16,15 @@ export class HeaderSource { ); } - hasHeader(): boolean { - return Boolean(this.getVersionHeader()); - } - - isAvailable(projectId: string | undefined): boolean { - return ( - this.isEnabled() && - projectId !== undefined && - this.getUpdatedAtHeader(projectId, this.getVersionHeader()) !== undefined - ); - } - /** Capture the header now so a shared fetch cannot switch the request being assessed. */ getAssessment(): CacheReadPolicy['assess'] { const header = this.getVersionHeader(); return (data) => { const headerTs = this.getUpdatedAtHeader(data.projectId, header); - if (headerTs === undefined) return { status: 'unknown' }; + if (headerTs === undefined) { + return { status: 'error' }; + } const currentTs = Number(data.configUpdatedAt); this.highestObserved = Math.max(this.highestObserved, headerTs); diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 5e2709d0e..650c1753c 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -11,7 +11,11 @@ import type { TrackEvaluationOptions } from '../utils/usage/flags-evaluation'; import { UsageTracker } from '../utils/usage-tracker'; import { unauthorizedMessage } from './auth'; import { BundledSource } from './bundled-source'; -import { type CacheReadPolicy, DatafileCache } from './datafile-cache'; +import { + type CacheReadPolicy, + type CacheResult, + DatafileCache, +} from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { HeaderSource } from './header-source'; import { @@ -443,37 +447,7 @@ export class Controller implements ControllerInterface { private async resolveRuntimeData(): Promise< [TaggedData, Metrics['cacheStatus']] > { - // A cold header read first discovers its own project through the shared fetch. - // Never infer ownership from another project's entry in the request header. - if ( - this.state === 'vercel' && - !this.cache.hasData && - this.headerSource.hasHeader() - ) { - try { - const result = await this.cache.resolve(this.cacheReadPolicy); - if ( - result && - (this.state !== 'vercel' || - this.headerSource.isAvailable(result[0].projectId)) - ) { - return result; - } - } catch (error) { - if (this.isShutdown) { - throw error; - } - // Without an identified project, headers cannot drive recovery. - // Continue through the same stream/poll fallback as a missing entry. - } - } - - if ( - this.state === 'vercel' && - !this.headerSource.isAvailable(this.cache.metadata?.projectId) - ) { - await this.activateFallbackSource('header'); - } else if (this.sourceStartup) { + if (this.sourceStartup) { await this.sourceStartup; } @@ -491,8 +465,26 @@ export class Controller implements ControllerInterface { return this.resolveStaticFallbackData(); } - const result = await this.cache.resolve(this.cacheReadPolicy); - if (result) return result; + const headerMode = this.state === 'vercel'; + let result: CacheResult | undefined; + try { + result = await this.cache.resolve(this.cacheReadPolicy); + } catch (error) { + if (!headerMode || this.cache.hasData || this.isShutdown) { + throw error; + } + // A failed cold fetch leaves headers unable to identify this client's project. + } + + if (result?.[2] || (!result && headerMode)) { + if (this.state === 'vercel') { + await this.activateFallbackSource('header'); + } + return this.resolveRuntimeData(); + } + if (result) { + return [result[0], result[1]]; + } return this.resolveStaticFallbackData(); } diff --git a/packages/vercel-flags-core/src/unified-reads.black-box.test.ts b/packages/vercel-flags-core/src/unified-reads.black-box.test.ts index 7382a5932..969db48a0 100644 --- a/packages/vercel-flags-core/src/unified-reads.black-box.test.ts +++ b/packages/vercel-flags-core/src/unified-reads.black-box.test.ts @@ -160,22 +160,74 @@ it.each([ it.each([ 'flags_prj_a=1', 'flags_prj_b=1', -])('discovers a cold client project before accepting header %s', async (header) => { + 'flags_prj_a=invalid', + '', + undefined, +])('serves a shared cold fetch and selects the source on the next read for header %s', async (header) => { context(header); dataFetch.mockResolvedValueOnce(Response.json(data())); const connection = stream(); streamFetch.mockResolvedValueOnce(connection.response); const instance = client('prj_a', { datafile: undefined }); + const [file, evaluation] = await Promise.all([ + instance.getDatafile(), + instance.evaluate('feature'), + ]); + expect(file).toMatchObject({ + projectId: 'prj_a', + metrics: { mode: 'vercel', cacheStatus: 'MISS' }, + }); + expect(evaluation).toMatchObject({ + value: true, + metrics: { mode: 'vercel', cacheStatus: 'MISS' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(streamFetch).not.toHaveBeenCalled(); + const reading = instance.getDatafile(); connection.push({ type: 'datafile', data: data() }); expect(await reading).toMatchObject({ projectId: 'prj_a', - metrics: { mode: header === 'flags_prj_a=1' ? 'vercel' : 'streaming' }, + metrics: { + mode: header === 'flags_prj_a=1' ? 'vercel' : 'streaming', + cacheStatus: 'HIT', + }, }); expect(dataFetch).toHaveBeenCalledTimes(1); expect(streamFetch).toHaveBeenCalledTimes(header === 'flags_prj_a=1' ? 0 : 1); }); +it.each([ + 'streaming', + 'polling', +] as const)('a source assessment error allows %s recovery after stale-if-error expires', async (mode) => { + context('flags_prj_a=2'); + const instance = client('prj_a', { + stream: mode === 'streaming', + staleWhileRevalidate: 0, + staleIfError: 0, + }); + dataFetch.mockResolvedValueOnce(new Response(null, { status: 403 })); + await expect(instance.getDatafile()).rejects.toThrow('Failed to fetch data'); + context('flags_other=2'); + const connection = stream(); + streamFetch.mockResolvedValueOnce(connection.response); + dataFetch.mockResolvedValueOnce(Response.json(data('prj_a', 2))); + const reading = instance.getDatafile(); + const evaluation = instance.evaluate('feature'); + connection.push({ type: 'datafile', data: data('prj_a', 2) }); + expect(await reading).toMatchObject({ + revision: 2, + metrics: { mode, cacheStatus: 'HIT' }, + }); + expect(await evaluation).toMatchObject({ + value: false, + metrics: { mode, cacheStatus: 'HIT' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 2 : 1); + expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); +}); + it('shares lazy initialization and returns header-aware HITs without fetching', async () => { const instance = client(); const [file, evaluation] = await Promise.all([ diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 831ba4a09..32800e054 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -219,12 +219,24 @@ describe('Vercel mode (black-box)', () => { } const stream = mockStream(); streamFetch.mockResolvedValueOnce(stream.response); + if (cache === 'empty') { + mockDatafileResponse(TIMESTAMP); + } mockDatafileResponse(TIMESTAMP + 1, true); const instance = client({ stream: mode === 'streaming', datafile: cache === 'provided' ? datafile() : undefined, }); + if (cache === 'empty') { + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { mode: 'vercel', source: 'remote', cacheStatus: 'MISS' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(streamFetch).not.toHaveBeenCalled(); + } + const reading = instance.evaluate('feature'); stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 1, true) }); await vi.advanceTimersByTimeAsync(0); @@ -237,7 +249,9 @@ describe('Vercel mode (black-box)', () => { }, }); expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); - expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 1 : 0); + const initialFetches = + (cache === 'empty' ? 1 : 0) + (mode === 'polling' ? 1 : 0); + expect(dataFetch).toHaveBeenCalledTimes(initialFetches); expect(readBundledDefinitions).toHaveBeenCalledTimes( cache === 'provided' ? 0 : 1, ); @@ -249,7 +263,7 @@ describe('Vercel mode (black-box)', () => { metrics: { mode, cacheStatus: 'HIT' }, }); expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); - expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 1 : 0); + expect(dataFetch).toHaveBeenCalledTimes(initialFetches); if (mode === 'streaming') { stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 2) }); @@ -262,7 +276,9 @@ describe('Vercel mode (black-box)', () => { value: false, metrics: { mode, cacheStatus: 'HIT' }, }); - expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 2 : 0); + expect(dataFetch).toHaveBeenCalledTimes( + initialFetches + (mode === 'polling' ? 1 : 0), + ); }); it.each([ @@ -464,20 +480,21 @@ describe('Vercel mode (black-box)', () => { expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 3 : 2); }); - it('retries an empty-cache fallback after startup fails', async () => { + it('retries an empty-cache fallback after the cold fetch and polling startup fail', async () => { const instance = client({ stream: false, datafile: undefined, disableMetrics: true, }); setVersion(undefined); + rejectDatafileOnce(new Error('cold fetch failed')); rejectDatafileOnce(new Error('poll failed')); const failure = expect(instance.evaluate('feature')).rejects.toThrow( 'poll failed', ); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(600); await failure; - expect(dataFetch).toHaveBeenCalledTimes(3); + expect(dataFetch).toHaveBeenCalledTimes(6); setVersion(TIMESTAMP + 100); mockDatafileResponse(TIMESTAMP + 1, true); @@ -485,7 +502,7 @@ describe('Vercel mode (black-box)', () => { value: true, metrics: { mode: 'polling' }, }); - expect(dataFetch).toHaveBeenCalledTimes(4); + expect(dataFetch).toHaveBeenCalledTimes(7); expect(streamFetch).not.toHaveBeenCalled(); }); From 4be115dc41ed396af229d7f80be4f51847c05c74 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 1 Oct 2026 16:13:14 +0200 Subject: [PATCH 37/41] fix(flags-core): unify polling startup and source assessment --- packages/vercel-flags-core/CLAUDE.md | 15 +- packages/vercel-flags-core/README.md | 14 +- .../vercel-flags-core/src/black-box.test.ts | 6 + .../controller/datafile-cache-policy.test.ts | 86 +++++--- .../src/controller/datafile-cache.ts | 39 ++-- .../src/controller/header-source.ts | 2 +- .../vercel-flags-core/src/controller/index.ts | 45 ++-- .../src/source-lifecycle.black-box.test.ts | 14 ++ .../src/stream-stale-if-error.test.ts | 41 +++- .../src/unified-reads.black-box.test.ts | 202 ++++++++++++++++-- .../src/vercel-mode.black-box.test.ts | 105 ++++----- 11 files changed, 402 insertions(+), 167 deletions(-) diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 60ce36031..53f8d15fb 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -141,8 +141,9 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu malformed, or unrelated headers) permanently start streaming if enabled, otherwise polling, using the existing startup timeouts. Clients select independently. A cold cache first loads definitions and discovers project identity via a shared HTTP fetch. - The next read checks source availability through the cache assessment; failed discovery - starts the stream/poll fallback immediately. + The next read checks source availability through the cache assessment. Missing or invalid + cached config versions also produce assessment errors. A failed cold fetch rejects + without switching sources; subsequent reads can retry the fetch. Concurrent new reads share source startup and pending HTTP refreshes. Accepted stream updates and valid confirmations cancel superseded HTTP work; waiting reads use the confirmed cache, and late responses cannot change failure or authorization state. @@ -168,7 +169,9 @@ Key behaviors: - For offline mode with existing data, `initialize()` returns immediately - **Never stream AND poll simultaneously** - If stream reconnects while polling → stop polling -- If stream disconnects → start an immediate background poll (if enabled), then interval polling. +- If stream disconnects → start an immediate poll (if enabled), then interval polling. + Reads share polling initialization and wait for the poll or its initialization timeout. + The detached disconnect handler catches startup rejection; waiting reads still receive it. Ping timeouts reconnect quietly without starting polling. - Use `buildStep: true` to force static-only mode (e.g., serverless cold starts) - Use `buildStep: false` to force runtime mode (e.g., custom build environments) @@ -296,7 +299,7 @@ When updating tests for new behavior, preserve the strength of existing assertio - Datafile fetches use three total attempts with 100ms and 200ms backoff for network, token, body parsing, and transient HTTP failures (408, 429, and 5xx). Other HTTP errors fail immediately. After exhausted retries, polling emits an error event and waits for the next interval. - Stops automatically when stream reconnects - `PollingSource` shares the cache's HTTP refresh for initialization, immediate fallback, and scheduled polls. Cache confirmation cancels superseded refreshes for stream evidence; the controller clears them on shutdown. Stopping the poller suppresses errors from its pending work. -- Initialization waits for the first poll up to `initTimeoutMs`. A timeout permits cached fallback without renewing cache age or failure allowance; the pending poll and recurring interval continue. +- Every transition to polling waits for the first poll up to `initTimeoutMs`, including stream disconnections with retained data. A timeout permits cached fallback subject to stale-if-error without renewing cache age or failure allowance; the pending poll and recurring interval continue. Zero waits for the poll, subject to its ten-second fetch deadline. - After runtime suspension, delayed intervals resume polling without changing sources. A request pending across suspension can hit its fetch deadline; the interval continues and a later successful poll clears the failure. - `fetchDatafile` owns a ten-second deadline covering token resolution, all attempts and backoff, and body parsing. It settles on timeout or cancellation even when a transport ignores its signal, and preserves the external abort reason. - Retries are enabled by default for every `fetchDatafile` caller: polling, build loading, offline initialization/evaluation, and direct `getDatafile()` fallback. Internal callers can override `maxAttempts`; retry scheduling and deadline handling remain in the fetch helper, independently of source classes and cache policy. @@ -361,7 +364,9 @@ superseded HTTP work only after data is accepted or validated. HTTP responses fi their own refresh, and request-header confirmations do not cancel pending work. `cache.resolve(policy)` receives a mode-specific `assess` callback returning -`{ status, confirmed? }`, where status is `fresh`, `stale`, `expired`, or `unknown`. +`{ status, confirmed? }`, where status is `fresh`, `stale`, `expired`, `unknown`, or `error`. +It returns `{ data, status, hasError? }`; `hasError` signals an assessment error to the +controller. Fetch failures still throw when no cached data can be served. The shared fetch callback is configured once on the cache. It owns background/blocking decisions, `waitUntil`, shared revalidation, and cancellation on clear. HeaderSource supplies small version/age checks; it does diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index d9f3b72f2..418388f16 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -40,7 +40,8 @@ or bundled definitions without starting a stream or polling. Request version hea indicate when cached definitions need refreshing. Header mode requires a valid positive version for the client’s own `projectId`. Missing, empty, malformed, or unrelated entries permanently switch that client to streaming when enabled, otherwise polling. Clients -with different projects select their sources independently within the same request. +also switch when cached definitions have no valid positive config version to compare. +Clients with different projects select their sources independently within the same request. Concurrent reads share that startup and later headers do not switch the client back. Pending HTTP refreshes remain shared until the stream delivers current data or confirms the cached version. That confirmation cancels the @@ -48,7 +49,8 @@ superseded refresh, and waiting reads use the confirmed cache; late responses ca change cache or authorization state. With an empty cache, the first read uses a shared fetch to load definitions and discover the client’s project. The next read assesses that project’s header entry and starts the stream/poll fallback if it is unavailable. -If the cold fetch fails, the client starts fallback immediately. +If the cold fetch fails, the read rejects without switching sources. Fetch failures +use cached data only while stale-if-error permits it; source assessment errors start fallback. ```ts const client = createClient(process.env.FLAGS!, { @@ -105,8 +107,12 @@ an error result for each requested flag, with its default value when provided. retained for recovery, including its revision for stream reconnection. A clean stream close records `stream: disconnected` if no earlier failure exists. Ping timeouts reconnect quietly without recording a failure or starting polling, including after runtime -suspension. Genuine disconnections start an immediate background poll, sharing pending -read refreshes, then continue at the configured interval. Stream recovery stops polling. `getFallbackDatafile()` remains an independent bundled-data export. +suspension. Genuine disconnections start an immediate poll, sharing pending read +refreshes, then continue at the configured interval. Every transition to polling +waits for the first poll or `polling.initTimeoutMs`, including reads with cached data. +On timeout, reads follow `staleIfError` while polling continues. A zero initialization +timeout waits for the poll, which still has a ten-second fetch deadline. Stream +recovery stops polling. `getFallbackDatafile()` remains an independent bundled-data export. Streaming data becomes stale after 60 seconds and expires after 90 seconds, allowing one missed 30-second ping before revalidation and matching the stream's ping diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index b0d722641..1cbe20dbd 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -1296,6 +1296,9 @@ describe('Controller (black-box)', () => { fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/v1/stream')) return stream.response; + if (url.includes('/v1/datafile')) { + return Promise.resolve(new Response(null, { status: 403 })); + } if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); @@ -1334,6 +1337,9 @@ describe('Controller (black-box)', () => { const body = new ReadableStream({ start() {} }); return Promise.resolve(new Response(body, { status: 200 })); } + if (url.includes('/v1/datafile')) { + return Promise.resolve(new Response(null, { status: 403 })); + } return Promise.resolve(new Response('', { status: 200 })); }); diff --git a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts index b63f267ba..789b6d433 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache-policy.test.ts @@ -63,7 +63,7 @@ describe('cache read callbacks', () => { cache.seed(original); expect( await cache.resolve({ assess: () => ({ status: 'error' }) }), - ).toEqual([original, 'STALE', true]); + ).toEqual({ data: original, status: 'STALE', hasError: true }); expect(cache.read()).toBe(original); expect(cache.ageMs).toBe(500); expect(fetch).not.toHaveBeenCalled(); @@ -77,9 +77,17 @@ describe('cache read callbacks', () => { const failure = new Error('original outage'); cache.fail(failure); const policy = { assess: () => ({ status: 'error' as const }) }; - expect(await cache.resolve(policy)).toEqual([original, 'STALE', true]); + expect(await cache.resolve(policy)).toEqual({ + data: original, + status: 'STALE', + hasError: true, + }); vi.setSystemTime(1_101); - expect(await cache.resolve(policy)).toEqual([undefined, 'STALE', true]); + expect(await cache.resolve(policy)).toEqual({ + data: undefined, + status: 'STALE', + hasError: true, + }); expect(() => cache.read()).toThrow(failure); expect(cache.ageMs).toBe(601); expect(fetch).not.toHaveBeenCalled(); @@ -92,14 +100,14 @@ describe('cache read callbacks', () => { expect( await Promise.all([cache.resolve({ assess }), cache.resolve({ assess })]), ).toEqual([ - [cache.read(), 'MISS'], - [cache.read(), 'MISS'], - ]); - expect(await cache.resolve({ assess })).toEqual([ - cache.read(), - 'STALE', - true, + { data: cache.read(), status: 'MISS' }, + { data: cache.read(), status: 'MISS' }, ]); + expect(await cache.resolve({ assess })).toEqual({ + data: cache.read(), + status: 'STALE', + hasError: true, + }); expect(assess).toHaveBeenCalledTimes(3); expect(fetch).toHaveBeenCalledTimes(1); }); @@ -116,10 +124,10 @@ describe('cache read callbacks', () => { assess: vi.fn(() => ({ status })), }; - expect(await cache.resolve(policy)).toEqual([ - original, - status === 'fresh' ? 'HIT' : 'STALE', - ]); + expect(await cache.resolve(policy)).toEqual({ + data: original, + status: status === 'fresh' ? 'HIT' : 'STALE', + }); expect(policy.assess).toHaveBeenCalledExactlyOnceWith({ projectId: 'prj_policy', environment: 'production', @@ -153,7 +161,10 @@ describe('cache read callbacks', () => { expect(waitUntil).not.toHaveBeenCalled(); pending.resolve(); await reading; - expect(settled).toHaveBeenCalledExactlyOnceWith([cache.read(), 'MISS']); + expect(settled).toHaveBeenCalledExactlyOnceWith({ + data: cache.read(), + status: 'MISS', + }); expect(cache.read()?.configUpdatedAt).toBe(2); }); @@ -169,10 +180,16 @@ describe('cache read callbacks', () => { cache.seed(original); const policy = { assess: () => ({ status: 'expired' as const }) }; cache.fail(firstError); - expect(await cache.resolve(policy)).toEqual([original, 'STALE']); + expect(await cache.resolve(policy)).toEqual({ + data: original, + status: 'STALE', + }); vi.setSystemTime(1_100); cache.fail(laterError); - expect(await cache.resolve(policy)).toEqual([original, 'STALE']); + expect(await cache.resolve(policy)).toEqual({ + data: original, + status: 'STALE', + }); vi.setSystemTime(1_101); await expect(cache.resolve(policy)).rejects.toBe(firstError); expect(fetch).not.toHaveBeenCalled(); @@ -202,7 +219,7 @@ describe('cache read callbacks', () => { cache.seed(original); expect( await cache.resolve({ assess: () => ({ status: 'stale' as const }) }), - ).toEqual([original, 'STALE']); + ).toEqual({ data: original, status: 'STALE' }); await waitUntil.mock.calls[0]?.[0]; expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); expect(fetch).toHaveBeenCalledTimes(1); @@ -225,7 +242,10 @@ describe('cache read callbacks', () => { const original = tagData(data(), 'provided'); cache.seed(original); const policy = { assess: () => ({ status: 'stale' as const }) }; - expect(await cache.resolve(policy)).toEqual([original, 'STALE']); + expect(await cache.resolve(policy)).toEqual({ + data: original, + status: 'STALE', + }); expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); const settled = vi.fn(); const blocking = cache @@ -239,7 +259,7 @@ describe('cache read callbacks', () => { expect(fetch).toHaveBeenCalledExactlyOnceWith(expect.any(AbortSignal)); pending.resolve(); - expect(await blocking).toEqual([cache.read(), 'MISS']); + expect(await blocking).toEqual({ data: cache.read(), status: 'MISS' }); await expect(waitUntil.mock.calls[0]?.[0]).resolves.toBeUndefined(); expect(cache.read()?.configUpdatedAt).toBe(2); }); @@ -252,7 +272,7 @@ describe('cache read callbacks', () => { cache.seed(tagData(data(), 'provided')); const policy = { assess: () => ({ status: 'stale' as const }) }; - expect((await cache.resolve(policy))?.[1]).toBe('STALE'); + expect((await cache.resolve(policy)).status).toBe('STALE'); await waitUntil.mock.calls[0]?.[0]; expect(errorSpy).toHaveBeenCalledExactlyOnceWith( '@vercel/flags-core: Revalidation failed:', @@ -276,7 +296,7 @@ describe('cache read callbacks', () => { const policy = { assess: () => ({ status: 'expired' as const }) }; await expect(cache.resolve(policy)).rejects.toBe(failure); fetch.mockResolvedValueOnce(data()); - expect((await cache.resolve(policy))?.[1]).toBe('MISS'); + expect((await cache.resolve(policy)).status).toBe('MISS'); expect(fetch).toHaveBeenCalledTimes(2); }); @@ -327,8 +347,8 @@ describe('cache read callbacks', () => { expect(fetch).toHaveBeenCalledTimes(2); nextPending.resolve(); expect(await Promise.all([nextRead, sharedRead])).toEqual([ - [cache.read(), 'MISS'], - [cache.read(), 'MISS'], + { data: cache.read(), status: 'MISS' }, + { data: cache.read(), status: 'MISS' }, ]); expect(cache.read()?.configUpdatedAt).toBe(2); }); @@ -378,7 +398,7 @@ describe('header freshness policy', () => { NaN, Infinity, 0, - ])('treats missing or invalid cached timestamp %s as unknown', (configUpdatedAt) => { + ])('treats missing or invalid cached timestamp %s as a source error', (configUpdatedAt) => { const headerSource = source(); expect( assessment( @@ -389,7 +409,7 @@ describe('header freshness policy', () => { configUpdatedAt, ageMs: 0, }), - ).toEqual({ status: 'unknown' }); + ).toEqual({ status: 'error' }); }); it.each([ @@ -437,7 +457,7 @@ describe('header freshness policy', () => { await cache.resolve({ assess: matching, }), - ).toEqual([original, 'HIT']); + ).toEqual({ data: original, status: 'HIT' }); expect(cache.ageMs).toBe(0); expect(original.fetchedAt).toBe(500); expect(original._origin).toBe('bundled'); @@ -447,7 +467,7 @@ describe('header freshness policy', () => { await cache.resolve({ assess: assessment(headerSource, 'flags_prj_policy=2'), }), - ).toEqual([original, 'STALE']); + ).toEqual({ data: original, status: 'STALE' }); const error = new Error('outage'); cache.fail(error); await expect( @@ -462,7 +482,7 @@ describe('header freshness policy', () => { await cache.resolve({ assess: assessment(headerSource, 'flags_prj_policy=1'), }), - ).toEqual([original, 'HIT']); + ).toEqual({ data: original, status: 'HIT' }); expect(cache.ageMs).toBe(0); expect(original.fetchedAt).toBe(500); }); @@ -487,8 +507,8 @@ describe('header freshness policy', () => { expect(laterCheck).not.toHaveBeenCalled(); pending.resolve(); expect(await Promise.all([firstRead, secondRead])).toEqual([ - [cache.read(), 'MISS'], - [cache.read(), 'MISS'], + { data: cache.read(), status: 'MISS' }, + { data: cache.read(), status: 'MISS' }, ]); expect(fetch).toHaveBeenCalledTimes(1); expect(originalCheck).toHaveReturnedWith({ status: 'stale' }); @@ -555,7 +575,7 @@ describe('header freshness policy', () => { vi.setSystemTime(2_000); expect( await cache.resolve({ assess: () => ({ status: 'expired' }) }), - ).toEqual([original, 'MISS']); + ).toEqual({ data: original, status: 'MISS' }); expect(fetchDatafile).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ signal: expect.any(AbortSignal) }), ); @@ -569,7 +589,7 @@ describe('header freshness policy', () => { await cache.resolve({ assess: assessment(headerSource, 'flags_prj_policy=2'), }), - ).toEqual([original, 'STALE']); + ).toEqual({ data: original, status: 'STALE' }); }); it('suppresses a successful transport response after cache clear cancels the fetch', async () => { diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 67256c859..3d63e6d53 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -19,9 +19,12 @@ export type CacheAssessment = { }; export type CacheFetch = (signal: AbortSignal) => Promise; -export type CacheResult = - | [TaggedData, Metrics['cacheStatus'], sourceError?: undefined] - | [TaggedData | undefined, 'STALE', sourceError: true]; +export type CacheResult = { + data: TaggedData | undefined; + status: Metrics['cacheStatus']; + hasError?: boolean; +}; + const SOURCE_CONFIRMED = new Error( 'Refresh superseded by a source confirmation', ); @@ -194,31 +197,37 @@ export class DatafileCache { return this.data; } - async resolve(policy: CacheReadPolicy): Promise { + async resolve(policy: CacheReadPolicy): Promise { const metadata = this.metadata; if (metadata) { const { status, confirmed } = policy.assess(metadata); if (status === 'error') { // Source availability is not a fetch failure. Preserve the failure deadline // and report the source error even when retained data can no longer be served. - return [this.canServe() ? this.read() : undefined, 'STALE', true]; + return { + data: this.canServe() ? this.read() : undefined, + status: 'STALE', + hasError: true, + }; } // Apply recovery evidence before read() enforces the failure deadline. if (confirmed) this.confirm(); if (status === 'fresh' || status === 'unknown') { // read() still enforces stale-if-error, even for a fresh assessment. - return [ - this.read()!, - status === 'fresh' && !this.failure ? 'HIT' : 'STALE', - ]; + return { + data: this.read()!, + status: status === 'fresh' && !this.failure ? 'HIT' : 'STALE', + }; } if (this.failure) { - if (!policy.retryOnFailure) return [this.read()!, 'STALE']; + if (!policy.retryOnFailure) { + return { data: this.read()!, status: 'STALE' }; + } if (this.canServe()) { const stale = this.read()!; this.fetchInBackground(); - return [stale, 'STALE']; + return { data: stale, status: 'STALE' }; } } @@ -227,7 +236,7 @@ export class DatafileCache { if (status === 'stale' && this.canServe()) { const stale = this.read()!; this.fetchInBackground(); - return [stale, 'STALE']; + return { data: stale, status: 'STALE' }; } } @@ -240,7 +249,7 @@ export class DatafileCache { // A live source supplied current data while this read awaited HTTP. // Shutdown uses a different reason and must still reject the read. if (signal.reason === SOURCE_CONFIRMED && this.data) { - return [this.read()!, 'HIT']; + return { data: this.read()!, status: 'HIT' }; } throw error; } @@ -248,7 +257,7 @@ export class DatafileCache { if (!stale) { throw error; } - return [stale, 'STALE']; + return { data: stale, status: 'STALE' }; } // Record the original request's evidence once a cold fetch discovers metadata. @@ -261,7 +270,7 @@ export class DatafileCache { const data = this.read(); if (!data) throw new Error('@vercel/flags-core: Fetch returned no definitions'); - return [data, 'MISS']; + return { data, status: 'MISS' }; } /** Runs the one shared datafile refresh used by reads and polling. */ diff --git a/packages/vercel-flags-core/src/controller/header-source.ts b/packages/vercel-flags-core/src/controller/header-source.ts index f0bac2715..0dec95180 100644 --- a/packages/vercel-flags-core/src/controller/header-source.ts +++ b/packages/vercel-flags-core/src/controller/header-source.ts @@ -30,7 +30,7 @@ export class HeaderSource { this.highestObserved = Math.max(this.highestObserved, headerTs); if (!Number.isFinite(currentTs) || currentTs <= 0) { - return { status: 'unknown' }; + return { status: 'error' }; } if (headerTs <= currentTs) { diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 650c1753c..9eea44730 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -11,11 +11,7 @@ import type { TrackEvaluationOptions } from '../utils/usage/flags-evaluation'; import { UsageTracker } from '../utils/usage-tracker'; import { unauthorizedMessage } from './auth'; import { BundledSource } from './bundled-source'; -import { - type CacheReadPolicy, - type CacheResult, - DatafileCache, -} from './datafile-cache'; +import { type CacheReadPolicy, DatafileCache } from './datafile-cache'; import { fetchDatafile } from './fetch-datafile'; import { HeaderSource } from './header-source'; import { @@ -213,7 +209,9 @@ export class Controller implements ControllerInterface { this.cache.fail(new Error('stream: disconnected')); if (this.state === 'streaming') { this.transition('degraded'); - void this.activateFallbackSource('stream'); + // Reads can await this shared startup, but the event handler has no caller. + // Handle its rejection too, including cancellation during shutdown. + void this.activateFallbackSource('stream').catch(() => {}); } }; private onSourceError = (error: Error) => { @@ -334,7 +332,9 @@ export class Controller implements ControllerInterface { // All update sources share the same final blocking datafile fetch. const fetched = await this.cache.resolve(this.cacheReadPolicy); - if (!fetched) await this.initializeFromFallbacks(); + if (!fetched.data) { + await this.initializeFromFallbacks(); + } } /** @@ -465,28 +465,14 @@ export class Controller implements ControllerInterface { return this.resolveStaticFallbackData(); } - const headerMode = this.state === 'vercel'; - let result: CacheResult | undefined; - try { - result = await this.cache.resolve(this.cacheReadPolicy); - } catch (error) { - if (!headerMode || this.cache.hasData || this.isShutdown) { - throw error; - } - // A failed cold fetch leaves headers unable to identify this client's project. - } + const result = await this.cache.resolve(this.cacheReadPolicy); - if (result?.[2] || (!result && headerMode)) { - if (this.state === 'vercel') { - await this.activateFallbackSource('header'); - } + if (result.hasError || !result.data) { + await this.activateFallbackSource('header'); return this.resolveRuntimeData(); } - if (result) { - return [result[0], result[1]]; - } - return this.resolveStaticFallbackData(); + return [result.data, result.status]; } private get cacheReadPolicy(): CacheReadPolicy { @@ -553,12 +539,7 @@ export class Controller implements ControllerInterface { ) { this.pollingSource.startInterval(); this.transition('polling'); - // Repair a disconnected stream immediately; reads share this background poll. - if (after === 'header') { - await this.initializePolling(); - } else { - void this.pollingSource.poll().catch(() => {}); - } + await this.initializePolling(); if (this.isShutdown) { throw new Error('@vercel/flags-core: Client is shut down'); } @@ -628,7 +609,7 @@ export class Controller implements ControllerInterface { // --------------------------------------------------------------------------- /** - * Waits for the first poll when polling is the primary runtime source. + * Waits for the first poll whenever polling becomes the active runtime source. * On timeout, initialization falls back while the pending poll and interval * continue in the background. Poll errors propagate if no data is cached or * the client is shutting down. diff --git a/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts b/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts index 39bac1027..2938cd344 100644 --- a/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts +++ b/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts @@ -447,6 +447,13 @@ it('cancels pending polling work when a stream reconnects', async () => { await vi.advanceTimersByTimeAsync(1_000); const signal = dataFetch.mock.calls[0]?.[1]?.signal; expect(dataFetch).toHaveBeenCalledTimes(1); + const settled = vi.fn(); + const reading = instance.evaluate('feature').then((result) => { + settled(); + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); second.push({ type: 'primed', revision: 2, @@ -455,6 +462,11 @@ it('cancels pending polling work when a stream reconnects', async () => { }); await vi.advanceTimersByTimeAsync(0); const abortedOnReconnect = signal?.aborted; + expect(settled).toHaveBeenCalledTimes(1); + expect(await reading).toMatchObject({ + value: true, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + }); pending.resolve( new Response(null, { status: 401, statusText: 'Unauthorized' }), ); @@ -464,6 +476,8 @@ it('cancels pending polling work when a stream reconnects', async () => { abortedOnReconnect: true, value: true, }); + await vi.advanceTimersByTimeAsync(30_000); + expect(dataFetch).toHaveBeenCalledTimes(1); }); it.each([ diff --git a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts index f07fde731..ee2e97e95 100644 --- a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts @@ -61,6 +61,7 @@ function mockStream() { const streamFetch = vi.fn(); const fetchMock = vi.fn(); +const dataFetch = vi.fn(); let clients: FlagsClient[]; let errorSpy: ReturnType; let warnSpy: ReturnType; @@ -109,12 +110,18 @@ async function expectExpired(instance: FlagsClient, error: Error) { await expect(instance.getDatafile()).rejects.toBe(error); } -function expectInitTimeout() { - expect(warnSpy.mock.calls).toEqual([ +function expectInitTimeout(pollTimedOut = false) { + const warnings = [ [ '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', ], - ]); + ]; + if (pollTimedOut) { + warnings.push([ + '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ]); + } + expect(warnSpy.mock.calls).toEqual(warnings); warnSpy.mockClear(); } @@ -126,11 +133,14 @@ beforeEach(() => { streamFetch .mockReset() .mockRejectedValue(new Error('unexpected stream fetch')); + // A failed fallback poll settles startup without confirming stream recovery. + dataFetch + .mockReset() + .mockImplementation(async () => new Response(null, { status: 403 })); fetchMock.mockReset().mockImplementation((input, init) => { if (String(input).endsWith('/v1/stream')) return streamFetch(input, init); if (String(input).endsWith('/v1/datafile')) { - // Keep fallback HTTP pending so only the tested stream evidence can recover. - return new Promise(() => {}); + return dataFetch(input, init); } return Promise.reject(new Error(`Unexpected fetch: ${String(input)}`)); }); @@ -161,6 +171,7 @@ describe('stream stale-if-error through the public API', () => { 'ping', 'primed', ] as const)('resets stream freshness on %s without changing the fetched snapshot', async (type) => { + dataFetch.mockImplementation(() => new Promise(() => {})); const { instance, stream } = await start({ staleIfError: 0 }); const initial = await instance.evaluate('flagA'); const snapshot = await instance.getDatafile(); @@ -191,6 +202,7 @@ describe('stream stale-if-error through the public API', () => { }); it('does not renew stream freshness on an invalid confirmation', async () => { + dataFetch.mockImplementation(() => new Promise(() => {})); const { instance, stream } = await start(); const snapshot = await instance.getDatafile(); await vi.advanceTimersByTimeAsync(60_001); @@ -523,6 +535,7 @@ describe('stream stale-if-error through the public API', () => { }); it('does not start SIE on initialization timeout or ping, but does on a late failure', async () => { + dataFetch.mockImplementation(() => new Promise(() => {})); const stream = mockStream(); streamFetch.mockResolvedValueOnce(stream.response); const supplied = data(); @@ -537,10 +550,12 @@ describe('stream stale-if-error through the public API', () => { await vi.advanceTimersByTimeAsync(0); expect(initialized).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); + expect(initialized).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(3_000); await initialization; expect(initialized).toHaveBeenCalledOnce(); - expectInitTimeout(); - await vi.advanceTimersByTimeAsync(9_999); + expectInitTimeout(true); + await vi.advanceTimersByTimeAsync(6_999); expect((await instance.evaluate('flagA')).value).toBe(true); // The immediate fallback poll is still pending; no response has confirmed recovery. expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); @@ -643,14 +658,16 @@ describe('stream stale-if-error through the public API', () => { expect(streamFetch).toHaveBeenCalledTimes(1); }); - it('forwards initial OIDC resolution failure without fetching or waiting for initialization timeout', async () => { + it('forwards initial OIDC resolution failure after fallback polling exhausts its token retries', async () => { vi.mocked(getVercelOidcToken).mockRejectedValue( new Error('OIDC unavailable'), ); const instance = client({ datafile: data(), staleIfError: 0 }, true); - const failure = await instance + const evaluation = instance .evaluate('flagA') .catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(300); + const failure = await evaluation; expect(failure).toBeInstanceOf(Error); expect(failure).toMatchObject({ message: 'stream: token resolution failed', @@ -661,8 +678,10 @@ describe('stream stale-if-error through the public API', () => { }), }); await expectExpired(instance, failure as Error); - expect(Date.now()).toBe(0); - await vi.advanceTimersByTimeAsync(60_000); + expect(Date.now()).toBe(300); + expect(getVercelOidcToken).toHaveBeenCalledTimes(4); + await vi.advanceTimersByTimeAsync(59_700); + expect(Date.now()).toBe(60_000); expect(getVercelOidcToken).toHaveBeenCalledTimes(8); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/packages/vercel-flags-core/src/unified-reads.black-box.test.ts b/packages/vercel-flags-core/src/unified-reads.black-box.test.ts index 969db48a0..8531f1f97 100644 --- a/packages/vercel-flags-core/src/unified-reads.black-box.test.ts +++ b/packages/vercel-flags-core/src/unified-reads.black-box.test.ts @@ -358,13 +358,24 @@ it('a missing project header starts streaming and a real disconnect starts exact await vi.advanceTimersByTimeAsync(0); expect(dataFetch).toHaveBeenCalledTimes(1); const signal = dataFetch.mock.calls[0]?.[1]?.signal; - // Reads with an exhausted error allowance fail until the shared poll recovers. - await expect(instance.getDatafile()).rejects.toThrow('stream: disconnected'); - await expect(instance.evaluate('feature')).rejects.toThrow( - 'stream: disconnected', - ); + const settled = vi.fn(); + const recoveryReads = Promise.all([ + instance.getDatafile(), + instance.evaluate('feature'), + ]).then((results) => { + settled(); + return results; + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); expect(dataFetch).toHaveBeenCalledTimes(1); pending.resolve(Response.json(data('prj_a', 2))); + for (const result of await recoveryReads) { + expect(result.metrics).toMatchObject({ + mode: 'polling', + cacheStatus: 'HIT', + }); + } await vi.advanceTimersByTimeAsync(0); expect(await instance.getDatafile()).toMatchObject({ revision: 2, @@ -383,36 +394,194 @@ it('a missing project header starts streaming and a real disconnect starts exact expect(streamFetch).toHaveBeenCalledTimes(2); }); -it('falls through to streaming when cold project discovery fails', async () => { +it('rejects a failed cold fetch without switching sources', async () => { context('flags_other=1'); dataFetch.mockResolvedValueOnce(new Response(null, { status: 403 })); + const instance = client('prj_a', { datafile: undefined, staleIfError: 0 }); + await expect(instance.getDatafile()).rejects.toThrow('Failed to fetch data'); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(streamFetch).not.toHaveBeenCalled(); +}); + +it.each([ + 0, 3_000, +])('waits for the immediate disconnect poll even with usable cache and timeout %i', async (initTimeoutMs) => { + context(); const connection = stream(); streamFetch.mockResolvedValueOnce(connection.response); - const instance = client('prj_a', { datafile: undefined, staleIfError: 0 }); - const reading = instance.getDatafile(); + const instance = client('prj_a', { + polling: { intervalMs: 30_000, initTimeoutMs }, + }); + const initial = instance.getDatafile(); connection.push({ type: 'datafile', data: data() }); - expect(await reading).toMatchObject({ - projectId: 'prj_a', - metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + await initial; + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + connection.close(); + await vi.advanceTimersByTimeAsync(0); + + const settled = vi.fn(); + const reading = Promise.all([ + instance.getDatafile(), + instance.evaluate('feature'), + ]).then((results) => { + settled(); + return results; }); + await vi.advanceTimersByTimeAsync(0); + const settledBeforePoll = settled.mock.calls.length; + pending.resolve(Response.json(data('prj_a', 2))); + const [file, evaluation] = await reading; + expect(settledBeforePoll).toBe(0); + expect(file).toMatchObject({ + revision: 2, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + expect(evaluation).toMatchObject({ + value: false, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + await vi.advanceTimersByTimeAsync(0); + expect((await instance.getDatafile()).revision).toBe(2); + expect(dataFetch).toHaveBeenCalledTimes(1); +}); + +it.each([ + Infinity, + 0, +])('enforces staleIfError %s after the disconnect poll initialization times out', async (staleIfError) => { + context(); + const connection = stream(); + const reconnect = stream(); + streamFetch + .mockResolvedValueOnce(connection.response) + .mockResolvedValueOnce(reconnect.response); + const instance = client('prj_a', { + staleIfError, + polling: { intervalMs: 30_000, initTimeoutMs: 3_000 }, + }); + const initial = instance.getDatafile(); + connection.push({ type: 'datafile', data: data() }); + await initial; + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + connection.close(); + await vi.advanceTimersByTimeAsync(0); + + const settled = vi.fn(); + const reading = Promise.allSettled([ + instance.getDatafile(), + instance.evaluate('feature'), + ]).then((results) => { + settled(); + return results; + }); + await vi.advanceTimersByTimeAsync(2_999); + expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + const results = await reading; + if (staleIfError === 0) { + expect(results).toEqual([ + { status: 'rejected', reason: new Error('stream: disconnected') }, + { status: 'rejected', reason: new Error('stream: disconnected') }, + ]); + } else { + expect(results).toMatchObject([ + { + status: 'fulfilled', + value: { + revision: 1, + metrics: { mode: 'polling', cacheStatus: 'STALE' }, + }, + }, + { + status: 'fulfilled', + value: { + value: true, + metrics: { mode: 'polling', cacheStatus: 'STALE' }, + }, + }, + ]); + } + expect(warnSpy.mock.calls).toEqual([ + [ + '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ], + ]); + warnSpy.mockClear(); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(dataFetch.mock.calls[0]?.[1]?.signal?.aborted).toBe(false); + pending.resolve(Response.json(data('prj_a', 2))); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.getDatafile()).toMatchObject({ + revision: 2, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + expect(dataFetch).toHaveBeenCalledTimes(1); +}); + +it.each([ + { initTimeoutMs: 0, withRead: false }, + { initTimeoutMs: 3_000, withRead: false }, + { initTimeoutMs: 0, withRead: true }, + { initTimeoutMs: 3_000, withRead: true }, +])('shuts down safely during the disconnect poll with timeout $initTimeoutMs and waiting read $withRead', async ({ + initTimeoutMs, + withRead, +}) => { + context(); + const connection = stream(); + streamFetch.mockResolvedValueOnce(connection.response); + const instance = client('prj_a', { + polling: { intervalMs: 30_000, initTimeoutMs }, + }); + const initial = instance.getDatafile(); + connection.push({ type: 'datafile', data: data() }); + await initial; + const pending = deferred(); + dataFetch.mockReturnValueOnce(pending.promise); + connection.close(); + await vi.advanceTimersByTimeAsync(0); + expect(dataFetch).toHaveBeenCalledTimes(1); + const signal = dataFetch.mock.calls[0]?.[1]?.signal; + const reading = withRead + ? expect(instance.getDatafile()).rejects.toMatchObject({ + name: 'AbortError', + }) + : undefined; + await vi.advanceTimersByTimeAsync(0); + await instance.shutdown(); + await reading; + clients.delete(instance); + expect(signal?.aborted).toBe(true); + pending.resolve(Response.json(data('prj_a', 2))); + await vi.advanceTimersByTimeAsync(30_001); expect(dataFetch).toHaveBeenCalledTimes(1); expect(streamFetch).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); }); -it('starts an immediate poll when a missing project header falls back to a failing stream', async () => { +it('starts an immediate poll and waits when a missing project header falls back to a failing stream', async () => { context('flags_other=1'); streamFetch.mockResolvedValueOnce(new Response(null, { status: 401 })); const pending = deferred(); dataFetch.mockReturnValueOnce(pending.promise); const instance = client(); - expect(await instance.getDatafile()).toMatchObject({ - revision: 1, - metrics: { mode: 'polling', cacheStatus: 'STALE' }, + const settled = vi.fn(); + const reading = instance.getDatafile().then((result) => { + settled(); + return result; }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).not.toHaveBeenCalled(); expect(Date.now()).toBe(now); expect(dataFetch).toHaveBeenCalledTimes(1); expect(streamFetch).toHaveBeenCalledTimes(1); pending.resolve(Response.json(data('prj_a', 2))); + expect(await reading).toMatchObject({ + revision: 2, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); await vi.advanceTimersByTimeAsync(0); expect(await instance.getDatafile()).toMatchObject({ revision: 2, @@ -442,9 +611,10 @@ it('shares an existing read refresh with the immediate disconnect poll', async ( expect(dataFetch).toHaveBeenCalledTimes(1); connection.close(); await vi.advanceTimersByTimeAsync(0); - expect((await instance.getDatafile()).metrics.mode).toBe('polling'); + const recoveryRead = instance.getDatafile(); expect(dataFetch).toHaveBeenCalledTimes(1); pending.resolve(Response.json(data('prj_a', 2))); + expect((await recoveryRead).metrics.mode).toBe('polling'); await vi.advanceTimersByTimeAsync(0); expect(await instance.getDatafile()).toMatchObject({ revision: 2, diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 32800e054..99471e38a 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -368,40 +368,28 @@ describe('Vercel mode (black-box)', () => { expect(streamFetch).not.toHaveBeenCalled(); }); - it('shares stream startup after a concurrent cold header discovery fails', async () => { + it('shares a failed cold fetch across requests without switching sources', async () => { const pendingHeader = deferred(); dataFetch.mockReturnValueOnce(pendingHeader.promise); const instance = client({ datafile: undefined, staleIfError: 0 }); - const settled = vi.fn(); - const originalRead = instance.evaluate('feature').then((result) => { - settled(); - return result; - }); + const originalRead = expect(instance.evaluate('feature')).rejects.toThrow( + 'Failed to fetch data: Unauthorized', + ); await vi.advanceTimersByTimeAsync(0); const signal = dataFetch.mock.calls[0]?.[1]?.signal; - const stream = mockStream(); - streamFetch.mockResolvedValueOnce(stream.response); setVersion(undefined); - const switchingRead = instance.evaluate('feature'); + const otherRead = expect(instance.getDatafile()).rejects.toThrow( + 'Failed to fetch data: Unauthorized', + ); await vi.advanceTimersByTimeAsync(0); expect(signal?.aborted).toBe(false); pendingHeader.resolve( new Response(null, { status: 401, statusText: 'Unauthorized' }), ); - await vi.advanceTimersByTimeAsync(0); - expect(settled).not.toHaveBeenCalled(); - - stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 1, true) }); - for (const result of await Promise.all([originalRead, switchingRead])) { - expect(result).toMatchObject({ - value: true, - metrics: { mode: 'streaming', cacheStatus: 'HIT' }, - }); - } - expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); + await Promise.all([originalRead, otherRead]); expect(dataFetch).toHaveBeenCalledTimes(1); - expect(streamFetch).toHaveBeenCalledTimes(1); + expect(streamFetch).not.toHaveBeenCalled(); }); it.each([ @@ -436,6 +424,10 @@ describe('Vercel mode (black-box)', () => { const settled = vi.fn(); void reading.then(settled); await vi.advanceTimersByTimeAsync(3_000); + if (mode === 'streaming') { + expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(3_000); + } expect(settled).toHaveBeenCalledTimes(1); pendingPoll.resolve(Response.json(datafile())); expect(await reading).toMatchObject({ @@ -446,9 +438,14 @@ describe('Vercel mode (black-box)', () => { }, }); if (mode === 'streaming') { - expect(warnSpy).toHaveBeenCalledExactlyOnceWith( - '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', - ); + expect(warnSpy.mock.calls).toEqual([ + [ + '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', + ], + [ + '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ], + ]); } else { expect(warnSpy).toHaveBeenCalledExactlyOnceWith( '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', @@ -480,7 +477,7 @@ describe('Vercel mode (black-box)', () => { expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 3 : 2); }); - it('retries an empty-cache fallback after the cold fetch and polling startup fail', async () => { + it('retries a failed cold fetch on the next read without switching sources', async () => { const instance = client({ stream: false, datafile: undefined, @@ -488,21 +485,20 @@ describe('Vercel mode (black-box)', () => { }); setVersion(undefined); rejectDatafileOnce(new Error('cold fetch failed')); - rejectDatafileOnce(new Error('poll failed')); const failure = expect(instance.evaluate('feature')).rejects.toThrow( - 'poll failed', + 'cold fetch failed', ); - await vi.advanceTimersByTimeAsync(600); + await vi.advanceTimersByTimeAsync(300); await failure; - expect(dataFetch).toHaveBeenCalledTimes(6); + expect(dataFetch).toHaveBeenCalledTimes(3); setVersion(TIMESTAMP + 100); mockDatafileResponse(TIMESTAMP + 1, true); expect(await instance.evaluate('feature')).toMatchObject({ value: true, - metrics: { mode: 'polling' }, + metrics: { mode: 'vercel', cacheStatus: 'MISS' }, }); - expect(dataFetch).toHaveBeenCalledTimes(7); + expect(dataFetch).toHaveBeenCalledTimes(4); expect(streamFetch).not.toHaveBeenCalled(); }); @@ -594,7 +590,7 @@ describe('Vercel mode (black-box)', () => { await vi.advanceTimersByTimeAsync(3_000); expect(await reading).toMatchObject({ value: false, - metrics: { mode: 'polling', cacheStatus: 'STALE' }, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, }); expect(warnSpy).toHaveBeenCalledExactlyOnceWith( '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', @@ -675,28 +671,23 @@ describe('Vercel mode (black-box)', () => { undefined, 0, 0.01, - ])('recovers via streaming when cold project discovery fails with staleIfError=%s', async (staleIfError) => { - const stream = mockStream(); - streamFetch.mockResolvedValueOnce(stream.response); + ])('rejects a failed cold fetch without cached fallback with staleIfError=%s', async (staleIfError) => { const instance = client({ datafile: undefined, staleIfError }); mockDatafileHttpFailure(); - const settled = vi.fn(); - const reading = instance.evaluate('feature').then((result) => { - settled(); - return result; - }); + const reading = expect(instance.evaluate('feature')).rejects.toThrow( + 'Failed to fetch data', + ); await vi.advanceTimersByTimeAsync(300); - expect(settled).not.toHaveBeenCalled(); + await reading; expect(dataFetch).toHaveBeenCalledTimes(3); - expect(streamFetch).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(11); - stream.push({ type: 'datafile', data: datafile(TIMESTAMP, true) }); - expect(await reading).toMatchObject({ + expect(streamFetch).not.toHaveBeenCalled(); + mockDatafileResponse(TIMESTAMP, true); + expect(await instance.evaluate('feature')).toMatchObject({ value: true, - metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + metrics: { mode: 'vercel', cacheStatus: 'MISS' }, }); expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP); - expect(dataFetch).toHaveBeenCalledTimes(3); + expect(dataFetch).toHaveBeenCalledTimes(4); }); it.each([ @@ -1699,13 +1690,27 @@ describe('Vercel mode (black-box)', () => { expect(dataFetch).not.toHaveBeenCalled(); }); - it('keeps cached definitions without a config version', async () => { + it.each([ + 'streaming', + 'polling', + ] as const)('replaces an unversioned cache through %s even with a valid project header', async (mode) => { setVersion(TIMESTAMP + 1); + const stream = mockStream(); + streamFetch.mockResolvedValueOnce(stream.response); + mockDatafileResponse(TIMESTAMP + 1, true); const instance = client({ datafile: { ...datafile(), configUpdatedAt: undefined }, + stream: mode === 'streaming', }); - expect((await instance.evaluate('feature')).value).toBe(false); - expect(dataFetch).not.toHaveBeenCalled(); + const reading = instance.evaluate('feature'); + stream.push({ type: 'datafile', data: datafile(TIMESTAMP + 1, true) }); + expect(await reading).toMatchObject({ + value: true, + metrics: { mode, cacheStatus: 'HIT' }, + }); + expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 1 : 0); + expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); }); it('rejects a blocking read on shutdown even if the transport ignores cancellation', async () => { From bca3f98fa75ee20691ed4cf617b0ecb173afaf5b Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Fri, 2 Oct 2026 13:22:24 +0200 Subject: [PATCH 38/41] refactor runtime source fallback handling --- .../src/controller/datafile-cache.test.ts | 12 +++++ .../src/controller/datafile-cache.ts | 23 +++++--- .../vercel-flags-core/src/controller/index.ts | 53 ++++++++++--------- .../src/controller/stream-connection.ts | 3 +- .../src/controller/stream-source.ts | 3 +- 5 files changed, 62 insertions(+), 32 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache.test.ts index 23d0cee37..dfa27d65f 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.test.ts @@ -230,6 +230,18 @@ describe('DatafileCache', () => { expect(cache.read()).toBe(original); }); + it('seeds and returns data through the serving boundary', () => { + const cache = new DatafileCache(unexpectedFetch, 100); + const original = data(); + expect(cache.seedAndRead(original)).toBe(original); + + const failure = new Error('first outage'); + cache.fail(failure); + vi.setSystemTime(1_101); + cache.clear(); + expect(() => cache.seedAndRead(data())).toThrow(failure); + }); + it('starts the inclusive allowance at the first failure, not storage time', () => { const cache = new DatafileCache(unexpectedFetch, 100); const original = { ...data('poll'), revision: 42 }; diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index 3d63e6d53..fb55a1ac6 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -111,6 +111,16 @@ export class DatafileCache { : undefined; } + /** Stores data and returns it through the serving boundary. */ + seedAndRead(data: TaggedData): TaggedData { + this.seed(data); + const seeded = this.read(); + if (!seeded) { + throw new Error('@vercel/flags-core: Seeded definitions unavailable'); + } + return seeded; + } + /** Accepts a source update or confirms the current version without replacing it. */ updateFromSource(incoming: DatafileInput, origin: DataOrigin): void { if (this.isNewerData(incoming)) { @@ -215,17 +225,17 @@ export class DatafileCache { if (status === 'fresh' || status === 'unknown') { // read() still enforces stale-if-error, even for a fresh assessment. return { - data: this.read()!, + data: this.read(), status: status === 'fresh' && !this.failure ? 'HIT' : 'STALE', }; } if (this.failure) { if (!policy.retryOnFailure) { - return { data: this.read()!, status: 'STALE' }; + return { data: this.read(), status: 'STALE' }; } if (this.canServe()) { - const stale = this.read()!; + const stale = this.read(); this.fetchInBackground(); return { data: stale, status: 'STALE' }; } @@ -234,7 +244,7 @@ export class DatafileCache { // If stale-if-error has expired, fall through to a blocking recovery fetch. // Calling read() here would throw before a background fetch could start. if (status === 'stale' && this.canServe()) { - const stale = this.read()!; + const stale = this.read(); this.fetchInBackground(); return { data: stale, status: 'STALE' }; } @@ -249,7 +259,7 @@ export class DatafileCache { // A live source supplied current data while this read awaited HTTP. // Shutdown uses a different reason and must still reject the read. if (signal.reason === SOURCE_CONFIRMED && this.data) { - return { data: this.read()!, status: 'HIT' }; + return { data: this.read(), status: 'HIT' }; } throw error; } @@ -268,8 +278,9 @@ export class DatafileCache { } // Serve the accepted cache entry; the response may have contained older data. const data = this.read(); - if (!data) + if (!data) { throw new Error('@vercel/flags-core: Fetch returned no definitions'); + } return { data, status: 'MISS' }; } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 9eea44730..41a86d305 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -53,8 +53,6 @@ type State = | 'build:ready' | 'shutdown'; -type RuntimeSource = 'header' | 'stream' | 'polling'; - // --------------------------------------------------------------------------- // Controller // --------------------------------------------------------------------------- @@ -208,10 +206,9 @@ export class Controller implements ControllerInterface { private onStreamDisconnected = () => { this.cache.fail(new Error('stream: disconnected')); if (this.state === 'streaming') { - this.transition('degraded'); // Reads can await this shared startup, but the event handler has no caller. // Handle its rejection too, including cancellation during shutdown. - void this.activateFallbackSource('stream').catch(() => {}); + void this.activateFallbackSource().catch(() => {}); } }; private onSourceError = (error: Error) => { @@ -322,7 +319,7 @@ export class Controller implements ControllerInterface { return; } - await this.activateFallbackSource('header'); + await this.activateFallbackSource(); if (this.cache.hasData) return; if (this.unauthorized) { throw this.noDefinitionsError( @@ -468,7 +465,7 @@ export class Controller implements ControllerInterface { const result = await this.cache.resolve(this.cacheReadPolicy); if (result.hasError || !result.data) { - await this.activateFallbackSource('header'); + await this.activateFallbackSource(); return this.resolveRuntimeData(); } @@ -502,11 +499,11 @@ export class Controller implements ControllerInterface { * Advances through the runtime source chain. Every caller uses the same path: * request headers → stream → polling → direct cache refresh. */ - private activateFallbackSource(after: RuntimeSource): Promise { + private activateFallbackSource(): Promise { if (this.sourceStartup) { return this.sourceStartup; } - const startup = this.startFallbackSource(after).finally(() => { + const startup = this.startFallbackSource().finally(() => { if (this.sourceStartup === startup) { this.sourceStartup = undefined; } @@ -515,26 +512,31 @@ export class Controller implements ControllerInterface { return startup; } - private async startFallbackSource(after: RuntimeSource): Promise { + private async startFallbackSource(): Promise { if (this.state === 'shutdown') { throw new Error('@vercel/flags-core: Client is shut down'); } - if (after === 'header' && this.options.stream.enabled) { + if ( + (this.state === 'idle' || this.state === 'vercel') && + this.options.stream.enabled + ) { this.transition('initializing:stream'); const connected = await this.tryInitializeStream(); if (this.isShutdown) { throw new Error('@vercel/flags-core: Client is shut down'); } - if (connected) { + if (connected || this.isConnected) { this.transition('streaming'); return; } - after = 'stream'; } if ( - (after === 'header' || after === 'stream') && + (this.state === 'idle' || + this.state === 'vercel' || + this.state === 'streaming' || + this.state === 'initializing:stream') && this.options.polling.enabled ) { this.pollingSource.startInterval(); @@ -691,12 +693,11 @@ export class Controller implements ControllerInterface { } const data = await this.buildDataPromise; - - if (!this.cache.hasData) { - this.cache.seed(data); - return [this.cache.read()!, 'MISS']; + const loaded = this.cache.read(); + if (loaded) { + return [loaded, 'HIT']; } - return [this.cache.read()!, 'HIT']; + return [this.cache.seedAndRead(data), 'MISS']; } /** @@ -796,17 +797,21 @@ export class Controller implements ControllerInterface { this.transition('initializing:fallback'); if (this.options.datafile) { - this.cache.seed(tagData({ ...this.options.datafile }, 'provided')); + const provided = this.cache.seedAndRead( + tagData({ ...this.options.datafile }, 'provided'), + ); this.transition('degraded'); - return [this.cache.read()!, 'STALE']; + return [provided, 'STALE']; } const bundled = await this.bundledSource.tryLoad(); if (bundled) { console.warn('@vercel/flags-core: Using bundled definitions as fallback'); - this.cache.seed(tagData({ ...bundled }, 'bundled')); + const embedded = this.cache.seedAndRead( + tagData({ ...bundled }, 'bundled'), + ); this.transition('degraded'); - return [this.cache.read()!, 'STALE']; + return [embedded, 'STALE']; } // Last resort: one-time fetch (only when no stream/poll configured) @@ -822,9 +827,9 @@ export class Controller implements ControllerInterface { // fetch failed — fall through to throw } if (fetched) { - this.cache.seed(tagFetchedData(fetched)); + const remote = this.cache.seedAndRead(tagFetchedData(fetched)); this.transition('degraded'); - return [this.cache.read()!, 'MISS']; + return [remote, 'MISS']; } } diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 0a275f6fa..2a5466d78 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -19,7 +19,8 @@ export type StreamMessage = const MAX_RETRY_COUNT = 15; const BASE_RETRY_DELAY_MS = 1000; const MAX_RETRY_DELAY_MS = 60_000; -export const PING_TIMEOUT_MS = 90_000; +export const PING_MS = 30_000; +export const PING_TIMEOUT_MS = PING_MS * 3; const PING_TIMEOUT = new Error('stream: ping timeout'); function backoff(retryCount: number): number { diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index 9f76d3a61..f16aadbfa 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -3,6 +3,7 @@ import type { CacheAssessment, CacheMetadata } from './datafile-cache'; import type { NormalizedOptions } from './normalized-options'; import { connectStream, + PING_MS, PING_TIMEOUT_MS, type PrimedMessage, } from './stream-connection'; @@ -35,7 +36,7 @@ export class StreamSource extends TypedEmitter { assess = ({ ageMs }: Pick): CacheAssessment => { // Pings arrive every 30s; tolerate one missed ping before revalidating. - if (ageMs <= (PING_TIMEOUT_MS * 2) / 3) { + if (ageMs <= PING_MS * 2) { return { status: 'fresh' }; } if (ageMs <= PING_TIMEOUT_MS) { From 25ed78eb00eaa9392cff7ad885bf9db77428bf69 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:25:22 +0000 Subject: [PATCH 39/41] fix(flags-core): keep stream and polling exclusive and treat any source response as recovery Polling now starts only once the stream has given up for good (retries exhausted, 401, or token failure); transient disconnects and startup timeouts keep serving the cache while the stream reconnects. Stream and polling freshness extend their fresh window by staleWhileRevalidate before reads block. getDatafile() is a snapshot again and never starts a source. Any successful source response renews cache age and clears the failure deadline even when the version guard keeps the stored data, a last-resort fetch clears failure too, and clear() resets the deadline so reinitialization after shutdown starts clean and rewires source events. A 401 on an empty cache no longer disables the client permanently. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WjWjQyvVvunuerU51GvB9M --- packages/adapter-vercel/src/index.test.ts | 44 +-- .../vercel-flags-core/src/black-box.test.ts | 40 +-- .../src/controller/datafile-cache.test.ts | 101 +++---- .../src/controller/datafile-cache.ts | 49 ++-- .../vercel-flags-core/src/controller/index.ts | 261 +++++++++++------- .../src/controller/polling-source.ts | 25 +- .../src/controller/stream-source.ts | 32 ++- .../src/create-raw-client.ts | 5 +- .../src/source-lifecycle.black-box.test.ts | 109 +++++--- .../src/stale-if-error.test.ts | 84 +++--- .../src/stream-stale-if-error.test.ts | 44 ++- .../src/unified-reads.black-box.test.ts | 225 ++++++--------- .../src/vercel-mode.black-box.test.ts | 94 +++---- 13 files changed, 557 insertions(+), 556 deletions(-) diff --git a/packages/adapter-vercel/src/index.test.ts b/packages/adapter-vercel/src/index.test.ts index 974b7a558..a7a49c465 100644 --- a/packages/adapter-vercel/src/index.test.ts +++ b/packages/adapter-vercel/src/index.test.ts @@ -222,8 +222,6 @@ describe('createVercelAdapter', () => { describe('when used with getProviderData', () => { let originalFlags: string | undefined; - const streamRequests = vi.fn(); - const datafileRequests = vi.fn(); beforeAll(() => { originalFlags = process.env.FLAGS; @@ -235,40 +233,12 @@ describe('when used with getProviderData', () => { }); beforeEach(() => { - vi.useFakeTimers(); - vi.stubEnv('CI', ''); - vi.stubEnv('NEXT_PHASE', ''); - streamRequests.mockClear(); - datafileRequests.mockClear(); resetDefaultFlagsClient(); resetDefaultVercelAdapter(); - // Discovery inherits getDatafile's lazy source initialization. Keep the stream - // open so this fixture does not trigger disconnect recovery after its first update. + // Mock the datafile endpoint for getDatafile server.use( - http.get('https://flags.vercel.com/v1/stream', () => { - streamRequests(); - return new HttpResponse( - new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - `${JSON.stringify({ - type: 'datafile', - data: { - projectId: 'prj_xxx', - definitions: {}, - segments: {}, - }, - })}\n`, - ), - ); - }, - }), - ); - }), http.get('https://flags.vercel.com/v1/datafile', () => { - datafileRequests(); return HttpResponse.json({ projectId: 'prj_xxx', definitions: {}, @@ -278,23 +248,13 @@ describe('when used with getProviderData', () => { ); }); - afterEach(async () => { - await flagsClient.shutdown(); - vi.useRealTimers(); - vi.unstubAllEnvs(); - }); - it('returns data', async () => { const testFlag = flag({ key: 'test-flag', adapter: vercelAdapter(), }); - const reading = getProviderData({ testFlag }); - await vi.advanceTimersByTimeAsync(0); - const providerData = await reading; - expect(streamRequests).toHaveBeenCalledTimes(1); - expect(datafileRequests).not.toHaveBeenCalled(); + const providerData = await getProviderData({ testFlag }); expect(providerData).toEqual({ definitions: { diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 1cbe20dbd..92daefa9f 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -231,12 +231,10 @@ describe('Controller (black-box)', () => { expect((await client.getDatafile()).configUpdatedAt).toBe( expectedVersion, ); - // Each API refreshes the still-expired seed after an older response. - const readRefreshes = configUpdatedAt < 2 ? 2 : 0; - expect(fetchMock).toHaveBeenCalledTimes(1 + readRefreshes); - expect(dataFetch).toHaveBeenCalledTimes( - (source === 'poll' ? 1 : 0) + readRefreshes, - ); + // An older response keeps the seed but still renews its freshness, so + // neither API starts a refresh of its own. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(dataFetch).toHaveBeenCalledTimes(source === 'poll' ? 1 : 0); } finally { cleanupContext(); try { @@ -1462,7 +1460,7 @@ describe('Controller (black-box)', () => { // Stream/polling coordination // --------------------------------------------------------------------------- describe('stream/polling coordination', () => { - it('should fall back to bundled when stream times out (refresh in background)', async () => { + it('should fall back to bundled when stream times out (skip polling)', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', definitions: makeBundled({ projectId: 'bundled' }), @@ -1502,16 +1500,17 @@ describe('Controller (black-box)', () => { const result = await client.evaluate('flagA', undefined, undefined); expect(result.metrics?.source).toBe('embedded'); - expect(pollCount).toBe(1); + // The stream keeps connecting in the background; polling never starts. + expect(pollCount).toBe(0); expect(warnSpy).toHaveBeenCalledExactlyOnceWith( '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', ); warnSpy.mockRestore(); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(1); await client.shutdown(); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenLastCalledWith( 'https://flags.vercel.com/v1/ingest', { @@ -1523,12 +1522,12 @@ describe('Controller (black-box)', () => { invocationHost: 'example.com', configOrigin: 'embedded', cacheStatus: 'HIT', - cacheAction: 'REFRESHING', + cacheAction: 'NONE', cacheIsFirstRead: true, cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, - mode: 'poll', + mode: 'offline', revision: '1', environment: 'production', }, @@ -1552,7 +1551,7 @@ describe('Controller (black-box)', () => { cleanupCtx(); }); - it('should use bundled definitions when stream fails after init timeout (polling fallback)', async () => { + it('should use bundled definitions when stream fails after init timeout (skip polling)', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', definitions: makeBundled({ projectId: 'bundled' }), @@ -1594,8 +1593,8 @@ describe('Controller (black-box)', () => { const result = await client.evaluate('flagA', undefined, undefined); expect(result.metrics?.source).toBe('embedded'); - // Fallback starts one immediate poll. - expect(pollCount).toBe(1); + // The stream is still retrying, so polling has not taken over. + expect(pollCount).toBe(0); expect(errorSpy).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledExactlyOnceWith( @@ -1616,12 +1615,12 @@ describe('Controller (black-box)', () => { invocationHost: 'example.com', configOrigin: 'embedded', cacheStatus: 'HIT', - cacheAction: 'REFRESHING', + cacheAction: 'NONE', cacheIsFirstRead: true, cacheIsBlocking: false, duration: 0, configUpdatedAt: 1, - mode: 'poll', + mode: 'offline', revision: '1', environment: 'production', }, @@ -1997,7 +1996,8 @@ describe('Controller (black-box)', () => { await vi.advanceTimersByTimeAsync(101); await initPromise; - expect(pollCount).toBe(1); + // Disconnects during retries never start polling; only an exhausted stream does. + expect(pollCount).toBe(0); expect(errorSpy).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledExactlyOnceWith( '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', @@ -2597,7 +2597,7 @@ describe('Controller (black-box)', () => { const result = await client.getDatafile(); expect(result.metrics.source).toBe('remote'); - expect(result.metrics.cacheStatus).toBe('STALE'); + expect(result.metrics.cacheStatus).toBe('MISS'); await client.shutdown(); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -2689,7 +2689,7 @@ describe('Controller (black-box)', () => { const result = await client.getDatafile(); expect(result.metrics.source).toBe('embedded'); - expect(result.metrics.cacheStatus).toBe('HIT'); + expect(result.metrics.cacheStatus).toBe('MISS'); await client.shutdown(); }); diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.test.ts b/packages/vercel-flags-core/src/controller/datafile-cache.test.ts index dfa27d65f..c6b086ff3 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.test.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.test.ts @@ -168,11 +168,10 @@ describe('DatafileCache', () => { expect(cache.ageMs).toBe(100); }); - it('clears storage and age without clearing the first failure', () => { + it('clears storage, age, and the first failure', () => { const cache = new DatafileCache(unexpectedFetch, 100); cache.updateFromSource(response(), 'fetched'); - const error = new Error('first outage'); - cache.fail(error); + cache.fail(new Error('first outage')); vi.setSystemTime(1_050); expect(cache.ageMs).toBe(50); cache.clear(); @@ -180,11 +179,13 @@ describe('DatafileCache', () => { expect(cache.read()).toBeUndefined(); expect(cache.tryConfirm(response())).toBe(false); expect(cache.ageMs).toBe(Infinity); - cache.seed(Object.freeze({ ...data(), fetchedAt: 1_050 })); + const restored = Object.freeze({ ...data(), fetchedAt: 1_050 }); + cache.seed(restored); expect(cache.ageMs).toBe(0); vi.setSystemTime(1_101); expect(cache.ageMs).toBe(51); - expect(() => cache.read()).toThrow(error); + // A restarted cache starts without the previous deadline. + expect(cache.read()).toBe(restored); }); }); @@ -238,8 +239,10 @@ describe('DatafileCache', () => { const failure = new Error('first outage'); cache.fail(failure); vi.setSystemTime(1_101); - cache.clear(); expect(() => cache.seedAndRead(data())).toThrow(failure); + cache.clear(); + const restored = data(); + expect(cache.seedAndRead(restored)).toBe(restored); }); it('starts the inclusive allowance at the first failure, not storage time', () => { @@ -309,29 +312,28 @@ describe('DatafileCache', () => { it.each([ 'provided', 'bundled', - ] as const)('restores %s seeds only within the original failure deadline', (origin) => { + ] as const)('restores %s seeds with a clean failure deadline after clear', (origin) => { const cache = new DatafileCache(unexpectedFetch, 100); const seed = data(origin); cache.seed(seed); - const firstError = new Error('first poll failed'); - cache.fail(firstError); + cache.fail(new Error('first poll failed')); vi.setSystemTime(1_050); cache.clear(); expect(cache.hasData).toBe(false); expect(cache.read()).toBeUndefined(); expect(cache.tryConfirm(response())).toBe(false); - cache.fail(new Error('poll still failing')); cache.seed(seed); expect(cache.read()).toBe(seed); - vi.setSystemTime(1_100); - expect(cache.read()).toBe(seed); - vi.setSystemTime(1_101); - cache.clear(); - cache.seed(seed); - cache.fail(new Error('poll failed again')); - expect(() => cache.read()).toThrow(firstError); + // The cleared deadline no longer applies; only a new failure starts one. + expect(cache.read()).toBe(seed); + const nextError = new Error('poll still failing'); + cache.fail(nextError); + vi.setSystemTime(1_201); + expect(cache.read()).toBe(seed); + vi.setSystemTime(1_202); + expect(() => cache.read()).toThrow(nextError); }); it('clears failure on a matching raw source response without replacing data', () => { @@ -760,7 +762,7 @@ describe('DatafileCache', () => { ] satisfies [ string, Partial, - ][])('rejects %s without mutation or clearing the original error/deadline', (_, overrides) => { + ][])('keeps the stored data for %s but treats the response as recovery', (_, overrides) => { const cache = new DatafileCache(unexpectedFetch, staleIfErrorMs); const original = Object.freeze(data('bundled')); cache.seed(original); @@ -770,25 +772,26 @@ describe('DatafileCache', () => { const error = new Error('first outage'); cache.fail(error); vi.setSystemTime(1_050); - - expect(cache.updateFromSource(incoming, 'poll')).toBeUndefined(); if (staleIfErrorMs === 0) { expect(() => cache.read()).toThrow(error); - } else { - expect(cache.read()).toBe(original); } + + // The version guard rejects the data, but the response proves the source is reachable. + expect(cache.updateFromSource(incoming, 'poll')).toBeUndefined(); + expect(cache.read()).toBe(original); + expect(cache.ageMs).toBe(0); expect(original._origin).toBe('bundled'); expect(incoming).toEqual(snapshot); expect(incoming).not.toHaveProperty('_origin'); - cache.fail(new Error('repeated outage')); - vi.setSystemTime(1_100); - if (staleIfErrorMs === 0) { - expect(() => cache.read()).toThrow(error); - } else { + + const nextError = new Error('repeated outage'); + cache.fail(nextError); + if (staleIfErrorMs > 0) { + vi.setSystemTime(1_050 + staleIfErrorMs); expect(cache.read()).toBe(original); } - vi.setSystemTime(1_101); - expect(() => cache.read()).toThrow(error); + vi.setSystemTime(1_051 + staleIfErrorMs); + expect(() => cache.read()).toThrow(nextError); expect(cache.tryConfirm(original)).toBe(true); expect(cache.read()).toBe(original); }); @@ -802,7 +805,7 @@ describe('DatafileCache', () => { ['Infinity', Infinity], [-Infinity, -Infinity], [-Infinity, '-Infinity'], - ])('cannot recover from a rejected response with nonfinite current version %s and incoming version %s', (current, next) => { + ])('recovers from a rejected response with nonfinite current version %s and incoming version %s without replacing data', (current, next) => { const cache = new DatafileCache(unexpectedFetch, staleIfErrorMs); const original = Object.freeze({ ...data('bundled'), @@ -818,25 +821,16 @@ describe('DatafileCache', () => { cache.updateFromSource(incoming, 'poll'); cache.updateFromSource(original, 'poll'); - if (staleIfErrorMs === 0) { - expect(() => cache.read()).toThrow(error); - } else { - expect(cache.read()).toBe(original); - } + expect(cache.read()).toBe(original); + expect(cache.ageMs).toBe(0); expect(original._origin).toBe('bundled'); expect(incoming).toEqual(snapshot); expect(incoming).not.toHaveProperty('_origin'); - vi.setSystemTime(1_100); - if (staleIfErrorMs === 0) { - expect(() => cache.read()).toThrow(error); - } else { - expect(cache.read()).toBe(original); - } - vi.setSystemTime(1_101); - expect(() => cache.read()).toThrow(error); + vi.setSystemTime(1_101 + staleIfErrorMs); + expect(cache.read()).toBe(original); }); - it('rejects an old response after an accepted replacement without renewing its failure deadline', () => { + it('keeps an accepted replacement after an old response while renewing its freshness', () => { const cache = new DatafileCache(unexpectedFetch, staleIfErrorMs); const oldResponse = Object.freeze(data('bundled')); cache.seed(oldResponse); @@ -852,23 +846,16 @@ describe('DatafileCache', () => { const error = new Error('replacement outage'); cache.fail(error); vi.setSystemTime(1_050); - - cache.updateFromSource(oldResponse, 'stream'); - expect(cache.ageMs).toBe(50); if (staleIfErrorMs === 0) { expect(() => cache.read()).toThrow(error); - } else { - expect(cache.read()).toBe(accepted); } + + cache.updateFromSource(oldResponse, 'stream'); + expect(cache.ageMs).toBe(0); + expect(cache.read()).toBe(accepted); expect(oldResponse._origin).toBe('bundled'); - vi.setSystemTime(1_100); - if (staleIfErrorMs === 0) { - expect(() => cache.read()).toThrow(error); - } else { - expect(cache.read()).toBe(accepted); - } - vi.setSystemTime(1_101); - expect(() => cache.read()).toThrow(error); + vi.setSystemTime(1_101 + staleIfErrorMs); + expect(cache.read()).toBe(accepted); expect(cache.tryConfirm(replacement)).toBe(true); expect(cache.ageMs).toBe(0); expect(cache.read()).toBe(accepted); diff --git a/packages/vercel-flags-core/src/controller/datafile-cache.ts b/packages/vercel-flags-core/src/controller/datafile-cache.ts index fb55a1ac6..fb7608aeb 100644 --- a/packages/vercel-flags-core/src/controller/datafile-cache.ts +++ b/packages/vercel-flags-core/src/controller/datafile-cache.ts @@ -121,23 +121,42 @@ export class DatafileCache { return seeded; } - /** Accepts a source update or confirms the current version without replacing it. */ + /** + * Accepts a newer source response, or keeps the stored data when the version + * guard rejects it. Every successful response proves the source is reachable, + * so it renews cache age and clears the failure either way. + */ updateFromSource(incoming: DatafileInput, origin: DataOrigin): void { if (this.isNewerData(incoming)) { this.data = tagData({ ...incoming, fetchedAt: Date.now() }, origin); this.confirm(origin); return; } - this.tryConfirm(incoming, 'configUpdatedAt', origin); + this.confirm(origin); } - /** Confirms a same-version source response without replacing stored data. */ + /** Confirms a same-version message without replacing stored data. */ tryConfirm( incoming: Confirmation, version: 'configUpdatedAt' | 'revision' = 'configUpdatedAt', source: ConfirmationSource = 'header', ): boolean { - if (!this.data) return false; + if (!this.matchesStored(incoming, version)) { + return false; + } + + this.confirm(source); + return true; + } + + /** Whether a message refers to the stored entry: finite equal version and same identity. */ + private matchesStored( + incoming: Confirmation, + version: 'configUpdatedAt' | 'revision', + ): boolean { + if (!this.data) { + return false; + } const currentTs = version === 'revision' @@ -147,18 +166,13 @@ export class DatafileCache { version === 'revision' ? incoming.revision : parseConfigUpdatedAt(incoming.configUpdatedAt); - if ( - !Number.isFinite(currentTs) || - !Number.isFinite(incomingTs) || - currentTs !== incomingTs || - this.data.projectId !== incoming.projectId || - this.data.environment !== incoming.environment - ) { - return false; - } - - this.confirm(source); - return true; + return ( + Number.isFinite(currentTs) && + Number.isFinite(incomingTs) && + currentTs === incomingTs && + this.data.projectId === incoming.projectId && + this.data.environment === incoming.environment + ); } /** Renews freshness and retires HTTP work superseded by stream evidence. */ @@ -334,12 +348,13 @@ export class DatafileCache { } } - /** Clearing storage is not recovery; restored seeds keep the failure deadline. */ + /** Resets storage, age, and the failure deadline so a restarted client begins clean. */ clear(): void { this.abortController.abort(); this.abortController = new AbortController(); this.fetching = undefined; this.data = undefined; this.freshAt = undefined; + this.failure = undefined; } } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 41a86d305..1134b4b87 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -29,9 +29,9 @@ export type { ControllerOptions } from './normalized-options'; export { PollingSource } from './polling-source'; export { StreamSource } from './stream-source'; -function tagFetchedData(data: DatafileInput): TaggedData { - return tagData({ ...data, fetchedAt: Date.now() }, 'fetched'); -} +const UNKNOWN_FRESHNESS: CacheReadPolicy = { + assess: () => ({ status: 'unknown' }), +}; // --------------------------------------------------------------------------- // Internal types @@ -43,7 +43,6 @@ function tagFetchedData(data: DatafileInput): TaggedData { type State = | 'idle' | 'initializing:stream' - | 'initializing:polling' | 'initializing:fallback' | 'streaming' | 'polling' @@ -68,9 +67,10 @@ type State = * - No streaming or polling * * **Runtime — streaming mode** (stream enabled): - * - Uses streaming with polling fallback when enabled + * - Streams exclusively; a startup timeout keeps connecting in the background * - Retains provided/bundled data during startup; fetches if the cache remains empty * - Stale reads refresh in the background; expired reads wait for refresh + * - Polling starts only once the stream gives up (retries exhausted or 401) * * **Runtime — polling mode** (polling enabled, stream disabled): * - Uses polling exclusively @@ -79,7 +79,7 @@ type State = * **Runtime — Vercel mode** (vercel enabled, with stream or polling enabled): * - Loads provided/bundled data before selecting the mode; no startup network * - HeaderSource checks request versions and refreshes when needed - * - A read without a valid project version header permanently starts stream/poll + * - An evaluation without a valid project version header permanently starts stream/poll * - Cache applies version acceptance and stale-if-error to all served data * * **Runtime — offline mode** (neither stream nor polling): @@ -107,8 +107,8 @@ export class Controller implements ControllerInterface { private bundledSource: BundledSource; private headerSource: HeaderSource; private sourceStartup: Promise | undefined; - // A startup timeout permits cached reads while the first update continues. - private startupFallback = false; + // The stream gave up for good; polling (if enabled) or HTTP reads take over. + private streamExhausted = false; // Usage tracking private usageTracker: UsageTracker; @@ -129,12 +129,10 @@ export class Controller implements ControllerInterface { try { const data = await fetchDatafile({ ...this.options, signal }); signal.throwIfAborted(); - this.startupFallback = false; this.unauthorized = false; return data; } catch (error) { signal.throwIfAborted(); - this.startupFallback = false; this.noteUnauthorized(error); throw error; } @@ -151,6 +149,7 @@ export class Controller implements ControllerInterface { this.pollingSource = new PollingSource({ polling: this.options.polling, + staleWhileRevalidateMs: this.options.staleWhileRevalidateMs, refresh: () => this.cache.refresh('poll'), }); this.headerSource = new HeaderSource(this.options); @@ -174,14 +173,11 @@ export class Controller implements ControllerInterface { // Source event handlers (stored for cleanup) private onStreamData = (data: DatafileInput) => { this.unauthorized = false; - this.startupFallback = false; this.cache.updateFromSource(data, 'stream'); }; private onStreamPrimed = (message: PrimedMessage) => { this.unauthorized = false; - if (this.cache.tryConfirm(message, 'revision', 'stream')) { - this.startupFallback = false; - } + this.cache.tryConfirm(message, 'revision', 'stream'); // The stream is connected even if its revision no longer matches the cache. if (this.state === 'degraded' || this.state === 'initializing:stream') { this.transition('streaming'); @@ -190,27 +186,28 @@ export class Controller implements ControllerInterface { private onStreamPing = () => { // Each connection sends primed/datafile before pings, so a ping confirms recovery. this.cache.confirm('stream'); - this.startupFallback = false; }; private onStreamConnected = () => { - if (this.state === 'polling') { - this.pollingSource.stop(); - this.transition('streaming'); - } else if ( - this.state === 'degraded' || - this.state === 'initializing:stream' - ) { + if (this.state === 'degraded' || this.state === 'initializing:stream') { this.transition('streaming'); } }; private onStreamDisconnected = () => { this.cache.fail(new Error('stream: disconnected')); + // The stream reconnects on its own; polling waits until it gives up. if (this.state === 'streaming') { - // Reads can await this shared startup, but the event handler has no caller. - // Handle its rejection too, including cancellation during shutdown. - void this.activateFallbackSource().catch(() => {}); + this.transition('degraded'); } }; + private onStreamExhausted = () => { + this.streamExhausted = true; + if (this.isShutdown) { + return; + } + // Reads can await this shared startup, but the event handler has no caller. + // Handle its rejection too, including cancellation during shutdown. + void this.activateFallbackSource().catch(() => {}); + }; private onSourceError = (error: Error) => { this.noteUnauthorized(error); this.cache.fail(error); @@ -226,6 +223,7 @@ export class Controller implements ControllerInterface { this.streamSource.on('ping', this.onStreamPing); this.streamSource.on('connected', this.onStreamConnected); this.streamSource.on('disconnected', this.onStreamDisconnected); + this.streamSource.on('exhausted', this.onStreamExhausted); this.streamSource.on('error', this.onSourceError); this.pollingSource.on('error', this.onSourceError); } @@ -236,6 +234,7 @@ export class Controller implements ControllerInterface { this.streamSource.off('ping', this.onStreamPing); this.streamSource.off('connected', this.onStreamConnected); this.streamSource.off('disconnected', this.onStreamDisconnected); + this.streamSource.off('exhausted', this.onStreamExhausted); this.streamSource.off('error', this.onSourceError); this.pollingSource.off('error', this.onSourceError); } @@ -291,6 +290,13 @@ export class Controller implements ControllerInterface { return; } + if (this.isShutdown) { + // Reinitialization after shutdown rewires the sources it stopped. + this.wireSourceEvents(); + this.streamExhausted = false; + this.transition('idle'); + } + // Hydrate from provided datafile if not already set (e.g., after shutdown) if (!this.cache.hasData && this.options.datafile) { this.cache.seed(tagData({ ...this.options.datafile }, 'provided')); @@ -345,25 +351,7 @@ export class Controller implements ControllerInterface { const [result, cacheStatus] = await this.resolveData(); - if (this.dataViewSource !== result) { - const { _origin, ...rest } = result; - this.dataViewBase = rest; - this.dataViewSource = result; - } - - const datafile = { - ...(this.dataViewBase as DatafileInput), - metrics: { - readMs: Date.now() - startTime, - source: originToMetricsSource(result._origin), - cacheStatus, - connectionState: this.isConnected - ? ('connected' as const) - : ('disconnected' as const), - mode: this.mode, - }, - } satisfies Datafile; - + const datafile = this.toDatafile(result, cacheStatus, startTime); this.trackRead(startTime, cacheHadDefinitions, isFirstRead, datafile); return datafile; } @@ -385,15 +373,37 @@ export class Controller implements ControllerInterface { } /** - * Resolves the datafile through the same freshness and source policy as reads. - * Builds the response without recording an evaluation read event. + * Returns the datafile with metrics as a snapshot: it never starts streaming + * or polling. Cached data is served through the active source policy + * (header checks and HTTP revalidation); an empty cache loads bundled + * definitions, then performs a one-time fetch. */ async getDatafile(): Promise { const startTime = Date.now(); this.isFirstGetData = false; - const [result, cacheStatus] = await this.resolveData(); + const [result, cacheStatus] = await this.resolveSnapshot(); + + const datafile = this.toDatafile(result, cacheStatus, startTime); + return datafile; + } + /** + * Returns the bundled fallback datafile. + */ + async getFallbackDatafile(): Promise { + return this.bundledSource.getRaw(); + } + + // --------------------------------------------------------------------------- + // Data resolution + // --------------------------------------------------------------------------- + + private toDatafile( + result: TaggedData, + cacheStatus: Metrics['cacheStatus'], + startTime: number, + ): Datafile { if (this.dataViewSource !== result) { const { _origin, ...rest } = result; this.dataViewBase = rest; @@ -414,17 +424,6 @@ export class Controller implements ControllerInterface { } satisfies Datafile; } - /** - * Returns the bundled fallback datafile. - */ - async getFallbackDatafile(): Promise { - return this.bundledSource.getRaw(); - } - - // --------------------------------------------------------------------------- - // Data resolution - // --------------------------------------------------------------------------- - /** * Resolves the current data, using the appropriate strategy for the * current mode. Returns tagged data and cache status. @@ -448,12 +447,6 @@ export class Controller implements ControllerInterface { await this.sourceStartup; } - if (!this.cache.hasData && this.unauthorized) { - throw this.noDefinitionsError( - '. Provide a datafile or bundled definitions.', - ); - } - if ( !this.cache.hasData && !this.options.stream.enabled && @@ -472,6 +465,62 @@ export class Controller implements ControllerInterface { return [result.data, result.status]; } + /** + * Resolves data for getDatafile() without starting a runtime source. + * Build step: cached → bundled → one-time fetch + * Runtime: cached data through the source policy; otherwise bundled → one-time fetch → throw + */ + private async resolveSnapshot(): Promise< + [TaggedData, Metrics['cacheStatus']] + > { + if (this.options.buildStep) { + return this.resolveDataForBuildStep(); + } + + if (this.sourceStartup) { + // Share a startup already in flight, but fall back on its failure. + await this.sourceStartup.catch(() => {}); + } + + // Header mode needs no network to select, so snapshots can use it too. + if (this.state === 'idle' && this.headerSource.isEnabled()) { + this.transition('vercel'); + } + + if (!this.cache.hasData) { + const bundled = await this.bundledSource.tryLoad(); + if (bundled) { + this.cache.seed(tagData({ ...bundled }, 'bundled')); + } + } + + if (this.cache.hasData || this.state === 'vercel') { + const result = await this.cache.resolve(this.cacheReadPolicy); + // A source assessment error does not switch sources here; the cache's + // failure policy still decides whether the retained entry can be served. + const cached = result.data ?? this.cache.read(); + if (cached) { + return [cached, result.status]; + } + } + + // One-time fetch as last resort + try { + await this.cache.refresh(); + } catch { + throw this.noDefinitionsError( + '. Initialize the client or provide a datafile.', + ); + } + const fetched = this.cache.read(); + if (!fetched) { + throw this.noDefinitionsError( + '. Initialize the client or provide a datafile.', + ); + } + return [fetched, 'MISS']; + } + private get cacheReadPolicy(): CacheReadPolicy { if (this.state === 'vercel') { return { @@ -480,19 +529,21 @@ export class Controller implements ControllerInterface { }; } - if (this.startupFallback) { - return { assess: () => ({ status: 'stale' }) }; - } - if (this.state === 'streaming') { return { assess: this.streamSource.assess }; } - if (this.state === 'polling' || this.state === 'initializing:polling') { + if (this.state === 'polling') { return { assess: this.pollingSource.assess }; } - return { assess: () => ({ status: 'unknown' }) }; + if (this.state === 'degraded' && this.streamExhausted) { + // No live source remains, so reads revalidate over HTTP on the stream's schedule. + return { assess: this.streamSource.assess }; + } + + // Startup and reconnects in progress: serve cached data until the source confirms it. + return UNKNOWN_FRESHNESS; } /** @@ -513,7 +564,7 @@ export class Controller implements ControllerInterface { } private async startFallbackSource(): Promise { - if (this.state === 'shutdown') { + if (this.isShutdown) { throw new Error('@vercel/flags-core: Client is shut down'); } @@ -522,6 +573,7 @@ export class Controller implements ControllerInterface { this.options.stream.enabled ) { this.transition('initializing:stream'); + this.streamExhausted = false; const connected = await this.tryInitializeStream(); if (this.isShutdown) { throw new Error('@vercel/flags-core: Client is shut down'); @@ -530,17 +582,16 @@ export class Controller implements ControllerInterface { this.transition('streaming'); return; } + if (!this.streamExhausted) { + // The stream keeps connecting in the background; polling waits until it gives up. + this.transition('degraded'); + return; + } } - if ( - (this.state === 'idle' || - this.state === 'vercel' || - this.state === 'streaming' || - this.state === 'initializing:stream') && - this.options.polling.enabled - ) { - this.pollingSource.startInterval(); + if (this.options.polling.enabled && this.state !== 'polling') { this.transition('polling'); + this.pollingSource.startInterval(); await this.initializePolling(); if (this.isShutdown) { throw new Error('@vercel/flags-core: Client is shut down'); @@ -587,7 +638,6 @@ export class Controller implements ControllerInterface { clearTimeout(timeoutId!); if (result === 'timeout') { - this.startupFallback = true; console.warn( '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', ); @@ -638,7 +688,6 @@ export class Controller implements ControllerInterface { }), ]); if (outcome === 'timeout') { - this.startupFallback = true; console.warn( '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', ); @@ -709,12 +758,8 @@ export class Controller implements ControllerInterface { // Fallback: one-time fetch try { - const fetched = await fetchDatafile({ - host: this.options.host, - auth: this.options.auth, - fetch: this.options.fetch, - }); - return tagFetchedData(fetched); + const fetched = await this.fetchOnce(); + return tagData({ ...fetched, fetchedAt: Date.now() }, 'fetched'); } catch (error) { this.noteUnauthorized(error); } @@ -728,6 +773,27 @@ export class Controller implements ControllerInterface { // Fallback helpers // --------------------------------------------------------------------------- + private fetchOnce(): Promise { + return fetchDatafile({ + host: this.options.host, + auth: this.options.auth, + fetch: this.options.fetch, + }); + } + + /** + * Stores a last-resort fetch as a source response, which clears any failure, + * and serves it through the cache boundary. + */ + private acceptLastResortFetch(fetched: DatafileInput): TaggedData { + this.cache.updateFromSource(fetched, 'fetched'); + const remote = this.cache.read(); + if (!remote) { + throw new Error('@vercel/flags-core: Fetch returned no definitions'); + } + return remote; + } + /** * Shared fallback chain used by both initialize() and resolveData(). */ @@ -749,12 +815,7 @@ export class Controller implements ControllerInterface { // Last resort: one-time fetch (only when no stream/poll configured) if (!this.options.stream.enabled && !this.options.polling.enabled) { try { - const fetched = await fetchDatafile({ - host: this.options.host, - auth: this.options.auth, - fetch: this.options.fetch, - }); - this.cache.seed(tagFetchedData(fetched)); + this.acceptLastResortFetch(await this.fetchOnce()); this.transition('degraded'); return; } catch { @@ -781,10 +842,8 @@ export class Controller implements ControllerInterface { } /** - * Retrieves data when the cache is empty or header mode is unavailable. - * Streaming mode: stream → datafile → bundled. - * Polling mode: poll → datafile → bundled. - * Offline mode: datafile → bundled → one-time fetch. + * Retrieves data when the cache is empty in offline mode: + * datafile → bundled → one-time fetch. */ private async resolveStaticFallbackData(): Promise< [TaggedData, Metrics['cacheStatus']] @@ -818,16 +877,12 @@ export class Controller implements ControllerInterface { if (!this.options.stream.enabled && !this.options.polling.enabled) { let fetched: DatafileInput | undefined; try { - fetched = await fetchDatafile({ - host: this.options.host, - auth: this.options.auth, - fetch: this.options.fetch, - }); + fetched = await this.fetchOnce(); } catch { // fetch failed — fall through to throw } if (fetched) { - const remote = this.cache.seedAndRead(tagFetchedData(fetched)); + const remote = this.acceptLastResortFetch(fetched); this.transition('degraded'); return [remote, 'MISS']; } diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index 52c5fdb4b..f13a7a241 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -6,6 +6,7 @@ export type PollingSourceConfig = { polling: { intervalMs: number; }; + staleWhileRevalidateMs: number; refresh: () => Promise; }; @@ -28,19 +29,27 @@ export class PollingSource extends TypedEmitter { this.config = config; } + /** Allow the scheduled poll its entire fetch deadline before revalidating. */ + get staleAfterMs(): number { + return this.config.polling.intervalMs + DEFAULT_FETCH_TIMEOUT_MS; + } + + /** Age after which reads block on a refresh. */ + get expiresAfterMs(): number { + return this.staleAfterMs + this.config.staleWhileRevalidateMs; + } + assess = ({ ageMs }: Pick): CacheAssessment => { - // Allow the scheduled poll its entire fetch deadline before revalidating. - const staleAt = this.config.polling.intervalMs + DEFAULT_FETCH_TIMEOUT_MS; - if (ageMs <= staleAt) { + if (ageMs === Infinity) { + // Nothing has confirmed this entry yet; keep serving it until a poll does. + return { status: 'unknown' }; + } + if (ageMs <= this.staleAfterMs) { return { status: 'fresh' }; } - - // Give the next scheduled poll a chance before making reads block. - const expiresAt = staleAt + this.config.polling.intervalMs; - if (ageMs <= expiresAt) { + if (ageMs <= this.expiresAfterMs) { return { status: 'stale' }; } - return { status: 'expired' }; }; diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index f16aadbfa..d074a9bdf 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -4,11 +4,13 @@ import type { NormalizedOptions } from './normalized-options'; import { connectStream, PING_MS, - PING_TIMEOUT_MS, type PrimedMessage, } from './stream-connection'; import { TypedEmitter } from './typed-emitter'; +/** Pings arrive every 30s; tolerate one missed ping before revalidating. */ +export const STREAM_FRESH_MS = PING_MS * 2; + export type StreamSourceEvents = { data: (data: DatafileInput) => void; primed: (message: PrimedMessage) => void; @@ -16,6 +18,8 @@ export type StreamSourceEvents = { connected: () => void; disconnected: () => void; error: (error: Error) => void; + /** The connection loop gave up (retries exhausted, 401, or token failure). */ + exhausted: () => void; }; /** @@ -34,12 +38,25 @@ export class StreamSource extends TypedEmitter { this.revision = revision; } + /** Age after which reads revalidate in the background. */ + get staleAfterMs(): number { + return STREAM_FRESH_MS; + } + + /** Age after which reads block on a refresh. */ + get expiresAfterMs(): number { + return STREAM_FRESH_MS + this.options.staleWhileRevalidateMs; + } + assess = ({ ageMs }: Pick): CacheAssessment => { - // Pings arrive every 30s; tolerate one missed ping before revalidating. - if (ageMs <= PING_MS * 2) { + if (ageMs === Infinity) { + // Nothing has confirmed this entry yet; keep serving it until the stream does. + return { status: 'unknown' }; + } + if (ageMs <= this.staleAfterMs) { return { status: 'fresh' }; } - if (ageMs <= PING_TIMEOUT_MS) { + if (ageMs <= this.expiresAfterMs) { return { status: 'stale' }; } return { status: 'expired' }; @@ -58,13 +75,15 @@ export class StreamSource extends TypedEmitter { // Clear cached state when the stream terminates so that a subsequent // start() call creates a fresh connection instead of returning a stale - // resolved promise. + // resolved promise. stop() clears the fields first, so reaching this + // listener with them still set means the connection loop gave up itself. abortController.signal.addEventListener( 'abort', () => { if (this.abortController === abortController) { this.promise = undefined; this.abortController = undefined; + this.emit('exhausted'); } }, { once: true }, @@ -112,8 +131,9 @@ export class StreamSource extends TypedEmitter { * Stop the stream connection. */ stop(): void { - this.abortController?.abort(); + const abortController = this.abortController; this.abortController = undefined; this.promise = undefined; + abortController?.abort(); } } diff --git a/packages/vercel-flags-core/src/create-raw-client.ts b/packages/vercel-flags-core/src/create-raw-client.ts index b579e2a6f..06a9c9d23 100644 --- a/packages/vercel-flags-core/src/create-raw-client.ts +++ b/packages/vercel-flags-core/src/create-raw-client.ts @@ -144,9 +144,10 @@ export function createCreateRawClient(fns: { }, getDatafile: async () => { const instance = controllerInstanceMap.get(id); - if (!instance?.initialized) { + // A snapshot shares initialization already in flight but never starts it. + if (instance?.initPromise) { try { - await api.initialize(); + await instance.initPromise; } catch { // Initialization failed — let getDatafile handle its own fallbacks } diff --git a/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts b/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts index 2938cd344..79c14fad2 100644 --- a/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts +++ b/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts @@ -291,19 +291,17 @@ it('keeps a watchdog on silent replacement streams without starting polling', as expect(dataFetch).toHaveBeenCalledTimes(1); }); -it.each([ - 503, 401, -])('falls back to polling when the replacement stream returns %i', async (status) => { +it('falls back to polling when the replacement stream returns 401', async () => { const first = stream(); streamFetch .mockResolvedValueOnce(first.response) - .mockResolvedValueOnce(new Response(null, { status })) - .mockImplementation(async () => stream().response); + .mockResolvedValueOnce(new Response(null, { status: 401 })); const instance = client(); const initial = instance.evaluate('feature'); first.push({ type: 'datafile', data: data(2) }); await initial; await vi.advanceTimersByTimeAsync(90_001); + // A 401 ends the stream for good, so polling takes over with an immediate poll. expect(dataFetch).toHaveBeenCalledTimes(1); expect((await instance.getDatafile()).metrics.mode).toBe('polling'); dataFetch.mockResolvedValueOnce(Response.json(data(3, false))); @@ -313,6 +311,37 @@ it.each([ value: false, metrics: { mode: 'polling', cacheStatus: 'HIT' }, }); + expect(streamFetch).toHaveBeenCalledTimes(2); +}); + +it('keeps reconnecting without polling when the replacement stream returns 503', async () => { + const first = stream(); + const third = stream(); + streamFetch + .mockResolvedValueOnce(first.response) + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValueOnce(third.response); + const instance = client(); + const initial = instance.evaluate('feature'); + first.push({ type: 'datafile', data: data(2) }); + await initial; + await vi.advanceTimersByTimeAsync(90_001); + expect(streamFetch).toHaveBeenCalledTimes(2); + // The failed replacement is retried with backoff; the cache is served meanwhile. + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'offline', cacheStatus: 'STALE' }, + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(streamFetch).toHaveBeenCalledTimes(3); + third.push({ type: 'datafile', data: data(3, false) }); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + }); + await vi.advanceTimersByTimeAsync(30_000); + expect(dataFetch).not.toHaveBeenCalled(); }); it('falls back to polling if silent reconnects exhaust the stream retry budget', async () => { @@ -431,7 +460,7 @@ it('does not invalidate a healthy stream when a retired header fetch fails late' expect(result).toMatchObject({ value: true, metrics: { mode: 'streaming' } }); }); -it('cancels pending polling work when a stream reconnects', async () => { +it('starts no HTTP work when a stream disconnects and reconnects', async () => { const first = stream(); const second = stream(); streamFetch @@ -441,19 +470,14 @@ it('cancels pending polling work when a stream reconnects', async () => { const initial = instance.evaluate('feature'); first.push({ type: 'datafile', data: data(2) }); await initial; - const pending = deferred(); - dataFetch.mockReturnValueOnce(pending.promise); first.close(); await vi.advanceTimersByTimeAsync(1_000); - const signal = dataFetch.mock.calls[0]?.[1]?.signal; - expect(dataFetch).toHaveBeenCalledTimes(1); - const settled = vi.fn(); - const reading = instance.evaluate('feature').then((result) => { - settled(); - return result; - }); - await vi.advanceTimersByTimeAsync(0); - expect(settled).not.toHaveBeenCalled(); + expect(streamFetch).toHaveBeenCalledTimes(2); + expect(dataFetch).not.toHaveBeenCalled(); + // With a zero allowance the disconnect fails reads until the stream confirms the cache. + await expect(instance.evaluate('feature')).rejects.toThrow( + 'stream: disconnected', + ); second.push({ type: 'primed', revision: 2, @@ -461,23 +485,12 @@ it('cancels pending polling work when a stream reconnects', async () => { environment: 'production', }); await vi.advanceTimersByTimeAsync(0); - const abortedOnReconnect = signal?.aborted; - expect(settled).toHaveBeenCalledTimes(1); - expect(await reading).toMatchObject({ + expect(await instance.evaluate('feature')).toMatchObject({ value: true, metrics: { mode: 'streaming', cacheStatus: 'HIT' }, }); - pending.resolve( - new Response(null, { status: 401, statusText: 'Unauthorized' }), - ); - await vi.advanceTimersByTimeAsync(0); - const result = await instance.evaluate('feature', false); - expect({ abortedOnReconnect, value: result.value }).toEqual({ - abortedOnReconnect: true, - value: true, - }); await vi.advanceTimersByTimeAsync(30_000); - expect(dataFetch).toHaveBeenCalledTimes(1); + expect(dataFetch).not.toHaveBeenCalled(); }); it.each([ @@ -493,7 +506,8 @@ it.each([ true, ], ['ping', { type: 'ping' }, true], - ['older datafile', { type: 'datafile', data: data(1) }, false], + // The version guard keeps the cache, but the stream delivered data again. + ['older datafile', { type: 'datafile', data: data(1) }, true], [ 'mismatched revision', { @@ -570,17 +584,18 @@ it('does not restart polling after shutdown during missing-header stream startup }); it.each([ - ['streaming', 30_000, 60_000, 90_000], - ['polling', 30_000, 40_000, 70_000], - ['polling', 45_000, 55_000, 100_000], -] as const)('uses background then blocking refresh for %s at interval %i', async (mode, intervalMs, staleAt, expiresAt) => { + ['streaming', 30_000, 10, 60_000, 70_000], + ['streaming', 30_000, 0, 60_000, 60_000], + ['polling', 30_000, 10, 40_000, 50_000], + ['polling', 45_000, 5, 55_000, 60_000], +] as const)('uses background then blocking refresh for %s at interval %i with staleWhileRevalidate %i', async (mode, intervalMs, staleWhileRevalidate, staleAt, expiresAt) => { const live = stream(); streamFetch.mockResolvedValueOnce(live.response); const instance = client({ stream: mode === 'streaming', polling: { intervalMs, initTimeoutMs: 3_000 }, - // This controls headers; scheduled sources use their own timing windows. - staleWhileRevalidate: 0, + // Scheduled sources extend their fresh window by this stale allowance. + staleWhileRevalidate, }); const initial = instance.evaluate('feature'); if (mode === 'streaming') { @@ -595,16 +610,18 @@ it.each([ vi.setSystemTime(now + staleAt); expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe('HIT'); expect(dataFetch).toHaveBeenCalledTimes(initialRequests); - vi.setSystemTime(now + staleAt + 1); - expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( - 'STALE', - ); - expect(dataFetch).toHaveBeenCalledTimes(initialRequests + 1); + if (expiresAt > staleAt) { + vi.setSystemTime(now + staleAt + 1); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + expect(dataFetch).toHaveBeenCalledTimes(initialRequests + 1); - vi.setSystemTime(now + expiresAt); - expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( - 'STALE', - ); + vi.setSystemTime(now + expiresAt); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( + 'STALE', + ); + } vi.setSystemTime(now + expiresAt + 1); const settled = vi.fn(); const reading = instance.evaluate('feature').then((result) => { diff --git a/packages/vercel-flags-core/src/stale-if-error.test.ts b/packages/vercel-flags-core/src/stale-if-error.test.ts index c45a950eb..6c368760b 100644 --- a/packages/vercel-flags-core/src/stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stale-if-error.test.ts @@ -296,26 +296,21 @@ describe('polling stale-if-error through the public API', () => { { configUpdatedAt: 9 }, { projectId: 'other' }, { environment: 'preview' }, - ])('does not renew polling freshness for a rejected response %j', async (override) => { + ])('renews polling freshness for a rejected response %j without replacing the snapshot', async (override) => { const instance = client({ polling: { intervalMs: 45_000, initTimeoutMs: 3_000 }, }); await instance.evaluate('flagA'); const snapshot = await instance.getDatafile(); - const revalidation = deferred(); poll.mockResolvedValueOnce(response(data(override))); - poll.mockReturnValueOnce(revalidation.promise); await vi.advanceTimersByTimeAsync(55_001); - expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe( - 'STALE', + // The response at 45s proves the source is reachable even though its data is rejected. + expect((await instance.evaluate('flagA')).metrics?.cacheStatus).toBe('HIT'); + expect(await instance.getDatafile()).toEqual(snapshot); + expect((await instance.getDatafile()).definitions).toBe( + snapshot.definitions, ); - expect(await instance.getDatafile()).toEqual({ - ...snapshot, - metrics: { ...snapshot.metrics, cacheStatus: 'STALE' }, - }); - expect(poll).toHaveBeenCalledTimes(3); - revalidation.resolve(response(data())); - await vi.advanceTimersByTimeAsync(0); + expect(poll).toHaveBeenCalledTimes(2); }); it('accepts fractional seconds and expires just after the inclusive millisecond deadline', async () => { @@ -357,14 +352,15 @@ describe('polling stale-if-error through the public API', () => { staleIfError: 0, ...(seed === 'provided' ? { datafile: supplied } : {}), }); - const snapshotRead = expect(instance.getDatafile()).rejects.toBe(failure); + // A snapshot never starts polling; it serves the seed before any failure exists. + const snapshotRead = instance.getDatafile(); const evaluation = instance.evaluate('flagA'); const evaluationOutcome = expect(evaluation).rejects.toBe(failure); await vi.advanceTimersByTimeAsync(301); await evaluationOutcome; await expect(instance.getDatafile()).rejects.toBe(failure); expect(Date.now()).toBe(301); - await snapshotRead; + expect((await snapshotRead).definitions).toBe(supplied.definitions); await vi.advanceTimersByTimeAsync(30_000); const snapshot = await instance.getDatafile(); expect(snapshot.definitions).toBe(supplied.definitions); @@ -455,37 +451,41 @@ describe('polling stale-if-error through the public API', () => { { environment: 'preview' }, { configUpdatedAt: NaN }, { configUpdatedAt: -Infinity }, - ])('does not confirm rejected data %j', async (override) => { + ])('recovers on rejected data %j without replacing the snapshot', async (override) => { const instance = client({ staleIfError: 0 }); await instance.evaluate('flagA'); const snapshot = await instance.getDatafile(); const failure = new Error('offline'); rejectPollOnce(failure); poll.mockResolvedValue(response(data(override))); - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(30_300); await expect(instance.evaluate('flagA')).rejects.toBe(failure); await expect(instance.getDatafile()).rejects.toBe(failure); + expect(poll).toHaveBeenCalledTimes(4); + // The next poll returns data the version guard rejects; the source is back anyway. + await vi.advanceTimersByTimeAsync(29_700); + expect((await instance.evaluate('flagA')).value).toBe(true); + const recovered = await instance.getDatafile(); + expect(recovered).toEqual(snapshot); + expect(recovered.definitions).toBe(snapshot.definitions); expect(poll).toHaveBeenCalledTimes(5); - poll.mockResolvedValueOnce(response(data())); - await vi.advanceTimersByTimeAsync(30_000); - expect((await instance.getDatafile()).definitions).toBe( - snapshot.definitions, - ); - expect(poll).toHaveBeenCalledTimes(6); }); it.each([ NaN, Infinity, -Infinity, - ])('does not confirm equal nonfinite version %s', async (configUpdatedAt) => { + ])('recovers on an equal nonfinite version %s', async (configUpdatedAt) => { poll.mockResolvedValue(response(data({ configUpdatedAt }))); const instance = client({ staleIfError: 0 }); await instance.evaluate('flagA'); const failure = new Error('offline'); rejectPollOnce(failure); - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(30_300); await expect(instance.evaluate('flagA')).rejects.toBe(failure); + await vi.advanceTimersByTimeAsync(29_700); + expect((await instance.evaluate('flagA')).value).toBe(true); + expect(poll).toHaveBeenCalledTimes(5); }); it('retains main acceptance of a newer mismatched identity and positive Infinity', async () => { @@ -517,24 +517,20 @@ describe('polling stale-if-error through the public API', () => { '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', ); warnSpy.mockClear(); - await vi.advanceTimersByTimeAsync(7_000); + // Cached reads do not start their own refresh while the first poll is pending. + await vi.advanceTimersByTimeAsync(6_999); + expect((await instance.evaluate('flagA')).value).toBe(true); + await vi.advanceTimersByTimeAsync(1); await expect(instance.evaluate('flagA')).rejects.toThrow( '@vercel/flags-core: Datafile fetch deadline exceeded', ); - expect(errorSpy).toHaveBeenCalledExactlyOnceWith( - '@vercel/flags-core: Revalidation failed:', - expect.objectContaining({ - message: '@vercel/flags-core: Datafile fetch deadline exceeded', - }), - ); - errorSpy.mockClear(); expect(poll).toHaveBeenCalledTimes(1); }); it.each([ 'provided', 'bundled', - ] as const)('does not renew an expired allowance when restoring a %s seed', async (seed) => { + ] as const)('starts a clean allowance after shutdown and reinitialization with a %s seed', async (seed) => { const supplied = data(); if (seed === 'bundled') { vi.mocked(readBundledDefinitions).mockResolvedValue({ @@ -552,15 +548,21 @@ describe('polling stale-if-error through the public API', () => { await vi.advanceTimersByTimeAsync(300); expect((await initial).value).toBe(true); await vi.advanceTimersByTimeAsync(101); + await expect(instance.evaluate('flagA')).rejects.toBe(failure); await instance.shutdown(); - // Main permits reinitialization but does not rewire source events. - // Restoring a seed must not turn that limitation into a policy bypass. - const restored = instance.evaluate('flagA'); - const restoredOutcome = expect(restored).rejects.toBe(failure); - await vi.advanceTimersByTimeAsync(300); - await restoredOutcome; - await expect(instance.getDatafile()).rejects.toBe(failure); - expect(poll).toHaveBeenCalledTimes(3); + + // Reinitialization rewires the sources and clears the previous deadline. + poll.mockResolvedValue(response(supplied)); + expect(await instance.evaluate('flagA')).toMatchObject({ + value: true, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, + }); + const restored = await instance.getDatafile(); + expect(restored.definitions).toBe(supplied.definitions); + expect(restored.metrics.source).toBe( + seed === 'provided' ? 'in-memory' : 'embedded', + ); + expect(poll).toHaveBeenCalledTimes(4); }); it('settles a timed-out poll before the next interval can recover', async () => { diff --git a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts index ee2e97e95..6715ebed8 100644 --- a/packages/vercel-flags-core/src/stream-stale-if-error.test.ts +++ b/packages/vercel-flags-core/src/stream-stale-if-error.test.ts @@ -264,9 +264,8 @@ describe('stream stale-if-error through the public API', () => { expectRequests(['0', '1']); await vi.advanceTimersByTimeAsync(1); expectRequests(['0', '1', '2']); - // These messages emit connected but neither confirms the cached snapshot. + // A mismatched primed message emits connected but does not confirm the cached snapshot. reconnect.push(primed({ revision: 6 })); - reconnect.push({ type: 'datafile', data: data({ configUpdatedAt: 9 }) }); await vi.advanceTimersByTimeAsync(1_000); expect(await instance.evaluate('flagA')).toMatchObject({ value: true, @@ -464,7 +463,11 @@ describe('stream stale-if-error through the public API', () => { expect(streamFetch).toHaveBeenCalledTimes(2); }); - it('does not confirm rejected same-version data with mismatched identity or older versions', async () => { + it.each([ + { configUpdatedAt: 9 }, + { projectId: 'other' }, + { environment: 'preview' }, + ])('recovers on rejected stream data %j without replacing the snapshot', async (override) => { const { instance, stream } = await start({ staleIfError: 0 }); const snapshot = await instance.getDatafile(); const reconnect = mockStream(); @@ -472,21 +475,17 @@ describe('stream stale-if-error through the public API', () => { const failure = new Error('outage'); stream.fail(failure); await vi.advanceTimersByTimeAsync(1_000); - for (const override of [ - { configUpdatedAt: 9 }, - { projectId: 'other' }, - { environment: 'preview' }, - ]) { - reconnect.push({ type: 'datafile', data: data(override) }); - await vi.advanceTimersByTimeAsync(0); - await expectExpired(instance, failure); - } - reconnect.push({ type: 'datafile', data: data() }); + await expectExpired(instance, failure); + // The version guard keeps the snapshot, but the stream delivered data again. + reconnect.push({ type: 'datafile', data: data(override) }); await vi.advanceTimersByTimeAsync(0); - expect((await instance.evaluate('flagA')).value).toBe(true); - expect((await instance.getDatafile()).definitions).toBe( - snapshot.definitions, - ); + expect(await instance.evaluate('flagA')).toMatchObject({ + value: true, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + }); + const recovered = await instance.getDatafile(); + expect(recovered.definitions).toBe(snapshot.definitions); + expect(recovered.configUpdatedAt).toBe(10); expectRequests(['0', '1']); }); @@ -550,15 +549,14 @@ describe('stream stale-if-error through the public API', () => { await vi.advanceTimersByTimeAsync(0); expect(initialized).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); - expect(initialized).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(3_000); await initialization; expect(initialized).toHaveBeenCalledOnce(); - expectInitTimeout(true); - await vi.advanceTimersByTimeAsync(6_999); + expectInitTimeout(); + await vi.advanceTimersByTimeAsync(9_999); expect((await instance.evaluate('flagA')).value).toBe(true); - // The immediate fallback poll is still pending; no response has confirmed recovery. - expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Promise)); + // The stream keeps connecting; cached reads neither poll nor refresh over HTTP. + expect(waitUntil).not.toHaveBeenCalled(); + expect(dataFetch).not.toHaveBeenCalled(); expect((await instance.getDatafile()).definitions).toBe( supplied.definitions, ); diff --git a/packages/vercel-flags-core/src/unified-reads.black-box.test.ts b/packages/vercel-flags-core/src/unified-reads.black-box.test.ts index 8531f1f97..527a5e531 100644 --- a/packages/vercel-flags-core/src/unified-reads.black-box.test.ts +++ b/packages/vercel-flags-core/src/unified-reads.black-box.test.ts @@ -134,13 +134,17 @@ it.each([ dataFetch.mockResolvedValueOnce(Response.json(data('prj_b', 2))); const first = client(); const second = client('prj_b', { stream: mode === 'streaming' }); - const reading = second.getDatafile(); + const reading = second.evaluate('feature'); connection.push({ type: 'datafile', data: data('prj_b', 2) }); expect(await first.evaluate('feature')).toMatchObject({ value: true, metrics: { mode: 'vercel', cacheStatus: 'HIT' }, }); expect(await reading).toMatchObject({ + value: false, + metrics: { mode, cacheStatus: 'HIT' }, + }); + expect(await second.getDatafile()).toMatchObject({ projectId: 'prj_b', revision: 2, metrics: { mode, cacheStatus: 'HIT' }, @@ -184,14 +188,17 @@ it.each([ expect(dataFetch).toHaveBeenCalledTimes(1); expect(streamFetch).not.toHaveBeenCalled(); - const reading = instance.getDatafile(); + // Only evaluations switch sources; snapshots follow the selected source. + const reading = instance.evaluate('feature'); connection.push({ type: 'datafile', data: data() }); + const mode = header === 'flags_prj_a=1' ? 'vercel' : 'streaming'; expect(await reading).toMatchObject({ + value: true, + metrics: { mode, cacheStatus: 'HIT' }, + }); + expect(await instance.getDatafile()).toMatchObject({ projectId: 'prj_a', - metrics: { - mode: header === 'flags_prj_a=1' ? 'vercel' : 'streaming', - cacheStatus: 'HIT', - }, + metrics: { mode, cacheStatus: 'HIT' }, }); expect(dataFetch).toHaveBeenCalledTimes(1); expect(streamFetch).toHaveBeenCalledTimes(header === 'flags_prj_a=1' ? 0 : 1); @@ -213,8 +220,10 @@ it.each([ const connection = stream(); streamFetch.mockResolvedValueOnce(connection.response); dataFetch.mockResolvedValueOnce(Response.json(data('prj_a', 2))); - const reading = instance.getDatafile(); const evaluation = instance.evaluate('feature'); + await vi.advanceTimersByTimeAsync(0); + // The snapshot shares the startup the evaluation began without starting its own. + const reading = instance.getDatafile(); connection.push({ type: 'datafile', data: data('prj_a', 2) }); expect(await reading).toMatchObject({ revision: 2, @@ -341,7 +350,7 @@ it('getDatafile enforces the first failure deadline and performs blocking recove expect(dataFetch).toHaveBeenCalledTimes(3); }); -it('a missing project header starts streaming and a real disconnect starts exactly one immediate shared poll', async () => { +it('a missing project header starts streaming and a disconnect reconnects without polling', async () => { context('flags_other=1'); const first = stream(); const second = stream(); @@ -349,48 +358,27 @@ it('a missing project header starts streaming and a real disconnect starts exact .mockResolvedValueOnce(first.response) .mockResolvedValueOnce(second.response); const instance = client('prj_a', { staleIfError: 0 }); - const reading = instance.getDatafile(); + const reading = instance.evaluate('feature'); first.push({ type: 'datafile', data: data() }); - expect((await reading).metrics.mode).toBe('streaming'); - const pending = deferred(); - dataFetch.mockReturnValueOnce(pending.promise); + expect((await reading).metrics?.mode).toBe('streaming'); + expect((await instance.getDatafile()).metrics.mode).toBe('streaming'); first.close(); await vi.advanceTimersByTimeAsync(0); - expect(dataFetch).toHaveBeenCalledTimes(1); - const signal = dataFetch.mock.calls[0]?.[1]?.signal; - const settled = vi.fn(); - const recoveryReads = Promise.all([ - instance.getDatafile(), - instance.evaluate('feature'), - ]).then((results) => { - settled(); - return results; - }); - await vi.advanceTimersByTimeAsync(0); - expect(settled).not.toHaveBeenCalled(); - expect(dataFetch).toHaveBeenCalledTimes(1); - pending.resolve(Response.json(data('prj_a', 2))); - for (const result of await recoveryReads) { - expect(result.metrics).toMatchObject({ - mode: 'polling', - cacheStatus: 'HIT', - }); - } - await vi.advanceTimersByTimeAsync(0); - expect(await instance.getDatafile()).toMatchObject({ - revision: 2, - metrics: { mode: 'polling', cacheStatus: 'HIT' }, - }); - expect(signal?.aborted).toBe(false); + // The disconnect is failure evidence, but recovery is left to the reconnecting stream. + await expect(instance.evaluate('feature')).rejects.toThrow( + 'stream: disconnected', + ); + await expect(instance.getDatafile()).rejects.toThrow('stream: disconnected'); + expect(dataFetch).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1_000); - second.push({ type: 'datafile', data: data('prj_a', 3) }); + second.push({ type: 'datafile', data: data('prj_a', 2) }); await vi.advanceTimersByTimeAsync(0); expect(await instance.getDatafile()).toMatchObject({ - revision: 3, + revision: 2, metrics: { mode: 'streaming', cacheStatus: 'HIT' }, }); await vi.advanceTimersByTimeAsync(30_000); - expect(dataFetch).toHaveBeenCalledTimes(1); + expect(dataFetch).not.toHaveBeenCalled(); expect(streamFetch).toHaveBeenCalledTimes(2); }); @@ -403,83 +391,68 @@ it('rejects a failed cold fetch without switching sources', async () => { expect(streamFetch).not.toHaveBeenCalled(); }); -it.each([ - 0, 3_000, -])('waits for the immediate disconnect poll even with usable cache and timeout %i', async (initTimeoutMs) => { +it('serves cached data immediately after a disconnect instead of waiting for a poll', async () => { context(); const connection = stream(); - streamFetch.mockResolvedValueOnce(connection.response); - const instance = client('prj_a', { - polling: { intervalMs: 30_000, initTimeoutMs }, - }); - const initial = instance.getDatafile(); + const reconnect = stream(); + streamFetch + .mockResolvedValueOnce(connection.response) + .mockResolvedValueOnce(reconnect.response); + const instance = client(); + const initial = instance.evaluate('feature'); connection.push({ type: 'datafile', data: data() }); await initial; - const pending = deferred(); - dataFetch.mockReturnValueOnce(pending.promise); connection.close(); await vi.advanceTimersByTimeAsync(0); - const settled = vi.fn(); - const reading = Promise.all([ + const [file, evaluation] = await Promise.all([ instance.getDatafile(), instance.evaluate('feature'), - ]).then((results) => { - settled(); - return results; - }); - await vi.advanceTimersByTimeAsync(0); - const settledBeforePoll = settled.mock.calls.length; - pending.resolve(Response.json(data('prj_a', 2))); - const [file, evaluation] = await reading; - expect(settledBeforePoll).toBe(0); + ]); expect(file).toMatchObject({ - revision: 2, - metrics: { mode: 'polling', cacheStatus: 'HIT' }, + revision: 1, + metrics: { + mode: 'offline', + cacheStatus: 'STALE', + connectionState: 'disconnected', + }, }); expect(evaluation).toMatchObject({ - value: false, - metrics: { mode: 'polling', cacheStatus: 'HIT' }, + value: true, + metrics: { mode: 'offline', cacheStatus: 'STALE' }, }); + expect(dataFetch).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1_000); + reconnect.push({ type: 'datafile', data: data('prj_a', 2) }); await vi.advanceTimersByTimeAsync(0); - expect((await instance.getDatafile()).revision).toBe(2); - expect(dataFetch).toHaveBeenCalledTimes(1); + expect(await instance.getDatafile()).toMatchObject({ + revision: 2, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + }); + expect(dataFetch).not.toHaveBeenCalled(); }); it.each([ Infinity, 0, -])('enforces staleIfError %s after the disconnect poll initialization times out', async (staleIfError) => { +])('enforces staleIfError %s on reads after a disconnect', async (staleIfError) => { context(); const connection = stream(); const reconnect = stream(); streamFetch .mockResolvedValueOnce(connection.response) .mockResolvedValueOnce(reconnect.response); - const instance = client('prj_a', { - staleIfError, - polling: { intervalMs: 30_000, initTimeoutMs: 3_000 }, - }); - const initial = instance.getDatafile(); + const instance = client('prj_a', { staleIfError }); + const initial = instance.evaluate('feature'); connection.push({ type: 'datafile', data: data() }); await initial; - const pending = deferred(); - dataFetch.mockReturnValueOnce(pending.promise); connection.close(); await vi.advanceTimersByTimeAsync(0); - const settled = vi.fn(); - const reading = Promise.allSettled([ + const results = await Promise.allSettled([ instance.getDatafile(), instance.evaluate('feature'), - ]).then((results) => { - settled(); - return results; - }); - await vi.advanceTimersByTimeAsync(2_999); - expect(settled).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - const results = await reading; + ]); if (staleIfError === 0) { expect(results).toEqual([ { status: 'rejected', reason: new Error('stream: disconnected') }, @@ -491,84 +464,55 @@ it.each([ status: 'fulfilled', value: { revision: 1, - metrics: { mode: 'polling', cacheStatus: 'STALE' }, + metrics: { mode: 'offline', cacheStatus: 'STALE' }, }, }, { status: 'fulfilled', value: { value: true, - metrics: { mode: 'polling', cacheStatus: 'STALE' }, + metrics: { mode: 'offline', cacheStatus: 'STALE' }, }, }, ]); } - expect(warnSpy.mock.calls).toEqual([ - [ - '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', - ], - ]); - warnSpy.mockClear(); - expect(dataFetch).toHaveBeenCalledTimes(1); - expect(dataFetch.mock.calls[0]?.[1]?.signal?.aborted).toBe(false); - pending.resolve(Response.json(data('prj_a', 2))); + expect(dataFetch).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1_000); + reconnect.push({ type: 'datafile', data: data('prj_a', 2) }); await vi.advanceTimersByTimeAsync(0); expect(await instance.getDatafile()).toMatchObject({ revision: 2, - metrics: { mode: 'polling', cacheStatus: 'HIT' }, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, }); - expect(dataFetch).toHaveBeenCalledTimes(1); + expect(dataFetch).not.toHaveBeenCalled(); }); -it.each([ - { initTimeoutMs: 0, withRead: false }, - { initTimeoutMs: 3_000, withRead: false }, - { initTimeoutMs: 0, withRead: true }, - { initTimeoutMs: 3_000, withRead: true }, -])('shuts down safely during the disconnect poll with timeout $initTimeoutMs and waiting read $withRead', async ({ - initTimeoutMs, - withRead, -}) => { +it('shuts down safely while the stream reconnects after a disconnect', async () => { context(); const connection = stream(); streamFetch.mockResolvedValueOnce(connection.response); - const instance = client('prj_a', { - polling: { intervalMs: 30_000, initTimeoutMs }, - }); - const initial = instance.getDatafile(); + const instance = client(); + const initial = instance.evaluate('feature'); connection.push({ type: 'datafile', data: data() }); await initial; - const pending = deferred(); - dataFetch.mockReturnValueOnce(pending.promise); connection.close(); await vi.advanceTimersByTimeAsync(0); - expect(dataFetch).toHaveBeenCalledTimes(1); - const signal = dataFetch.mock.calls[0]?.[1]?.signal; - const reading = withRead - ? expect(instance.getDatafile()).rejects.toMatchObject({ - name: 'AbortError', - }) - : undefined; - await vi.advanceTimersByTimeAsync(0); await instance.shutdown(); - await reading; clients.delete(instance); - expect(signal?.aborted).toBe(true); - pending.resolve(Response.json(data('prj_a', 2))); await vi.advanceTimersByTimeAsync(30_001); - expect(dataFetch).toHaveBeenCalledTimes(1); + expect(dataFetch).not.toHaveBeenCalled(); expect(streamFetch).toHaveBeenCalledTimes(1); expect(vi.getTimerCount()).toBe(0); }); -it('starts an immediate poll and waits when a missing project header falls back to a failing stream', async () => { +it('starts polling and waits when a missing project header falls back to a stream that gives up', async () => { context('flags_other=1'); streamFetch.mockResolvedValueOnce(new Response(null, { status: 401 })); const pending = deferred(); dataFetch.mockReturnValueOnce(pending.promise); const instance = client(); const settled = vi.fn(); - const reading = instance.getDatafile().then((result) => { + const reading = instance.evaluate('feature').then((result) => { settled(); return result; }); @@ -579,7 +523,7 @@ it('starts an immediate poll and waits when a missing project header falls back expect(streamFetch).toHaveBeenCalledTimes(1); pending.resolve(Response.json(data('prj_a', 2))); expect(await reading).toMatchObject({ - revision: 2, + value: false, metrics: { mode: 'polling', cacheStatus: 'HIT' }, }); await vi.advanceTimersByTimeAsync(0); @@ -590,7 +534,7 @@ it('starts an immediate poll and waits when a missing project header falls back expect(dataFetch).toHaveBeenCalledTimes(1); }); -it('shares an existing read refresh with the immediate disconnect poll', async () => { +it('finishes a pending read refresh across a disconnect without polling', async () => { context(); const connection = stream(); const reconnect = stream(); @@ -598,7 +542,7 @@ it('shares an existing read refresh with the immediate disconnect poll', async ( .mockResolvedValueOnce(connection.response) .mockResolvedValueOnce(reconnect.response); const instance = client(); - const reading = instance.getDatafile(); + const reading = instance.evaluate('feature'); connection.push({ type: 'datafile', data: data() }); await reading; await vi.advanceTimersByTimeAsync(60_001); @@ -611,18 +555,21 @@ it('shares an existing read refresh with the immediate disconnect poll', async ( expect(dataFetch).toHaveBeenCalledTimes(1); connection.close(); await vi.advanceTimersByTimeAsync(0); - const recoveryRead = instance.getDatafile(); + // Reads keep serving the cache while the stream reconnects; no poll starts. + expect((await instance.getDatafile()).metrics).toMatchObject({ + mode: 'offline', + cacheStatus: 'STALE', + }); expect(dataFetch).toHaveBeenCalledTimes(1); pending.resolve(Response.json(data('prj_a', 2))); - expect((await recoveryRead).metrics.mode).toBe('polling'); await vi.advanceTimersByTimeAsync(0); - expect(await instance.getDatafile()).toMatchObject({ - revision: 2, - metrics: { mode: 'polling', cacheStatus: 'HIT' }, - }); - expect(dataFetch).toHaveBeenCalledTimes(1); + expect((await instance.getDatafile()).revision).toBe(2); + await vi.advanceTimersByTimeAsync(1_000); reconnect.push({ type: 'datafile', data: data('prj_a', 3) }); await vi.advanceTimersByTimeAsync(30_000); - expect((await instance.getDatafile()).metrics.mode).toBe('streaming'); + expect(await instance.getDatafile()).toMatchObject({ + revision: 3, + metrics: { mode: 'streaming', cacheStatus: 'HIT' }, + }); expect(dataFetch).toHaveBeenCalledTimes(1); }); diff --git a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts index 99471e38a..578a38710 100644 --- a/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts +++ b/packages/vercel-flags-core/src/vercel-mode.black-box.test.ts @@ -307,13 +307,14 @@ describe('Vercel mode (black-box)', () => { expect(warnSpy).toHaveBeenCalledExactlyOnceWith( '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', ); + // The matching header just confirmed the entry, so polling judges it fresh. expect(await first).toMatchObject({ value: false, - metrics: { mode: 'polling', cacheStatus: 'STALE' }, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, }); expect((await second).feature).toMatchObject({ value: false, - metrics: { mode: 'polling', cacheStatus: 'STALE' }, + metrics: { mode: 'polling', cacheStatus: 'HIT' }, }); pending.resolve(Response.json(datafile(TIMESTAMP + 1, true))); @@ -418,39 +419,32 @@ describe('Vercel mode (black-box)', () => { const stream = mockStream(); streamFetch.mockResolvedValueOnce(stream.response); const pendingPoll = deferred(); - dataFetch.mockReturnValueOnce(pendingPoll.promise); + if (mode === 'polling') { + dataFetch.mockReturnValueOnce(pendingPoll.promise); + } setVersion(undefined); const reading = instance.evaluate('feature'); const settled = vi.fn(); void reading.then(settled); await vi.advanceTimersByTimeAsync(3_000); - if (mode === 'streaming') { - expect(settled).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(3_000); - } + // A stream startup timeout serves the cache without starting polling. expect(settled).toHaveBeenCalledTimes(1); - pendingPoll.resolve(Response.json(datafile())); + if (mode === 'polling') { + pendingPoll.resolve(Response.json(datafile())); + } expect(await reading).toMatchObject({ value: true, metrics: { source: 'remote', - cacheStatus: 'STALE', + // Polling judges the 33-second-old entry fresh; a connecting stream has no verdict yet. + cacheStatus: mode === 'polling' ? 'HIT' : 'STALE', }, }); - if (mode === 'streaming') { - expect(warnSpy.mock.calls).toEqual([ - [ - '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', - ], - [ - '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', - ], - ]); - } else { - expect(warnSpy).toHaveBeenCalledExactlyOnceWith( - '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', - ); - } + expect(warnSpy).toHaveBeenCalledExactlyOnceWith( + mode === 'streaming' + ? '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background' + : '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', + ); const retained = await instance.getDatafile(); expect(retained.configUpdatedAt).toBe(TIMESTAMP + 1); expect(retained.definitions).toBe(snapshot.definitions); @@ -459,7 +453,7 @@ describe('Vercel mode (black-box)', () => { seed === 'bundled' ? 1 : 0, ); expect(streamFetch).toHaveBeenCalledTimes(mode === 'streaming' ? 1 : 0); - expect(dataFetch).toHaveBeenCalledTimes(2); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 2 : 1); // A later source update must replace the cache, not reuse a completed fallback result. setVersion(TIMESTAMP + 100); @@ -474,7 +468,7 @@ describe('Vercel mode (black-box)', () => { value: false, metrics: { mode, cacheStatus: 'HIT' }, }); - expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 3 : 2); + expect(dataFetch).toHaveBeenCalledTimes(mode === 'polling' ? 3 : 1); }); it('retries a failed cold fetch on the next read without switching sources', async () => { @@ -560,12 +554,8 @@ describe('Vercel mode (black-box)', () => { expect(warnSpy).toHaveBeenCalledExactlyOnceWith( '@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background', ); - expect(errorSpy).toHaveBeenCalledExactlyOnceWith( - '@vercel/flags-core: Revalidation failed:', - expect.objectContaining({ - message: '@vercel/flags-core: Datafile fetch deadline exceeded', - }), - ); + // Cached reads do not start their own refresh while the poll is pending. + expect(errorSpy).not.toHaveBeenCalled(); mockDatafileResponse(TIMESTAMP + 2, true); await vi.advanceTimersByTimeAsync(20_000); @@ -588,9 +578,10 @@ describe('Vercel mode (black-box)', () => { setVersion(undefined); const reading = instance.evaluate('feature'); await vi.advanceTimersByTimeAsync(3_000); + // The cache is served while the stream keeps connecting; polling does not start. expect(await reading).toMatchObject({ value: false, - metrics: { mode: 'polling', cacheStatus: 'HIT' }, + metrics: { mode: 'offline', cacheStatus: 'STALE' }, }); expect(warnSpy).toHaveBeenCalledExactlyOnceWith( '@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background', @@ -603,7 +594,7 @@ describe('Vercel mode (black-box)', () => { metrics: { mode: 'streaming', cacheStatus: 'HIT' }, }); expect(streamFetch).toHaveBeenCalledTimes(1); - expect(dataFetch).toHaveBeenCalledTimes(1); + expect(dataFetch).not.toHaveBeenCalled(); }); it.each([ @@ -1417,7 +1408,7 @@ describe('Vercel mode (black-box)', () => { it.each([ 0, -1, - ])('retains a background response with version delta %i and renews age only on confirmation', async (delta) => { + ])('retains cached data after a background response with version delta %i and renews its age', async (delta) => { const instance = client(); await instance.evaluate('feature'); vi.setSystemTime(TIMESTAMP + 9_000); @@ -1436,9 +1427,10 @@ describe('Vercel mode (black-box)', () => { vi.setSystemTime(TIMESTAMP + 10_001); setVersion(TIMESTAMP + 1); mockDatafileResponse(TIMESTAMP + 1, true); + // The rejected response still renewed the age, so this read stays within the stale window. expect(await instance.evaluate('feature')).toMatchObject({ - value: delta !== 0, - metrics: { cacheStatus: delta === 0 ? 'STALE' : 'MISS' }, + value: false, + metrics: { cacheStatus: 'STALE' }, }); await vi.advanceTimersByTimeAsync(0); expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 1); @@ -1500,8 +1492,9 @@ describe('Vercel mode (black-box)', () => { setVersion(TIMESTAMP + 2); mockDatafileResponse(TIMESTAMP + 2, true); + // Both responses renewed the age, so the newer header refreshes in the background. expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe( - delta === 0 ? 'STALE' : 'MISS', + 'STALE', ); await vi.advanceTimersByTimeAsync(0); expect((await instance.getDatafile()).configUpdatedAt).toBe(TIMESTAMP + 2); @@ -1628,13 +1621,12 @@ describe('Vercel mode (black-box)', () => { dataFetch.mockReturnValueOnce(pending.promise); const settled = vi.fn(); const read = instance.getDatafile().finally(settled); - const outcome = - delta < 0 - ? expect(read).rejects.toBe(failure) - : expect(read).resolves.toMatchObject({ - configUpdatedAt: TIMESTAMP + delta, - metrics: { cacheStatus: 'MISS' }, - }); + // Only a newer response replaces data; equal or older responses confirm recovery. + const retained = TIMESTAMP + Math.max(delta, 0); + const outcome = expect(read).resolves.toMatchObject({ + configUpdatedAt: retained, + metrics: { cacheStatus: 'MISS' }, + }); await vi.advanceTimersByTimeAsync(0); expect(settled).not.toHaveBeenCalled(); expect(dataFetch).toHaveBeenCalledTimes(4); @@ -1642,14 +1634,9 @@ describe('Vercel mode (black-box)', () => { pending.resolve(Response.json(datafile(TIMESTAMP + delta, true))); await outcome; setVersion(TIMESTAMP - 1); - if (delta < 0) { - await expect(instance.getDatafile()).rejects.toBe(failure); - return; - } - // A matching response confirms recovery without replacing or retagging data. expect(await instance.getDatafile()).toMatchObject({ - configUpdatedAt: TIMESTAMP + delta, - fetchedAt: TIMESTAMP + delta, + configUpdatedAt: retained, + fetchedAt: retained, }); }); @@ -1665,11 +1652,14 @@ describe('Vercel mode (black-box)', () => { cleanupContext = setRequestContext({ [HEADER]: header }); mockDatafileResponse(TIMESTAMP + 1, true); const instance = client({ stream: false }); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { cacheStatus: 'HIT', mode: 'polling' }, + }); expect(await instance.getDatafile()).toMatchObject({ configUpdatedAt: TIMESTAMP + 1, metrics: { cacheStatus: 'HIT', mode: 'polling' }, }); - expect((await instance.evaluate('feature')).value).toBe(true); expect(dataFetch).toHaveBeenCalledTimes(1); expect(streamFetch).not.toHaveBeenCalled(); }); From 586cf61bd76252a3048bcbc671783948205a1d7d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:32:33 +0000 Subject: [PATCH 40/41] fix(flags-core): let reads refresh after the stream is exhausted without polling With a recorded failure the cache otherwise waits for the live source to recover, but a stream-only client has no source left once the stream gives up. Reads in that state now revalidate over HTTP, including after a failed refresh. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WjWjQyvVvunuerU51GvB9M --- .../vercel-flags-core/src/controller/index.ts | 5 ++-- .../src/source-lifecycle.black-box.test.ts | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 1134b4b87..1b1af7473 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -538,8 +538,9 @@ export class Controller implements ControllerInterface { } if (this.state === 'degraded' && this.streamExhausted) { - // No live source remains, so reads revalidate over HTTP on the stream's schedule. - return { assess: this.streamSource.assess }; + // No live source remains to recover from a failure, so reads revalidate + // over HTTP on the stream's schedule, including after a failed refresh. + return { assess: this.streamSource.assess, retryOnFailure: true }; } // Startup and reconnects in progress: serve cached data until the source confirms it. diff --git a/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts b/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts index 79c14fad2..5c9aab432 100644 --- a/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts +++ b/packages/vercel-flags-core/src/source-lifecycle.black-box.test.ts @@ -344,6 +344,35 @@ it('keeps reconnecting without polling when the replacement stream returns 503', expect(dataFetch).not.toHaveBeenCalled(); }); +it('revalidates over HTTP once the stream gives up and polling is disabled', async () => { + const first = stream(); + streamFetch + .mockResolvedValueOnce(first.response) + .mockResolvedValueOnce(new Response(null, { status: 401 })); + const instance = client({ polling: false }); + const initial = instance.evaluate('feature'); + first.push({ type: 'datafile', data: data(2) }); + await initial; + await vi.advanceTimersByTimeAsync(90_001); + expect(streamFetch).toHaveBeenCalledTimes(2); + expect(dataFetch).not.toHaveBeenCalled(); + // No live source remains, so the read itself refreshes the entry over HTTP. + dataFetch.mockResolvedValueOnce(Response.json(data(3, false))); + expect(await instance.evaluate('feature')).toMatchObject({ + value: true, + metrics: { mode: 'offline', cacheStatus: 'STALE' }, + }); + await vi.advanceTimersByTimeAsync(0); + expect(await instance.evaluate('feature')).toMatchObject({ + value: false, + metrics: { mode: 'offline', cacheStatus: 'HIT' }, + }); + await vi.advanceTimersByTimeAsync(60_000); + expect((await instance.evaluate('feature')).metrics?.cacheStatus).toBe('HIT'); + expect(dataFetch).toHaveBeenCalledTimes(1); + expect(streamFetch).toHaveBeenCalledTimes(2); +}); + it('falls back to polling if silent reconnects exhaust the stream retry budget', async () => { const first = stream(); streamFetch From 356954d878b40747d9eb20999934777ad858217b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:30:19 +0000 Subject: [PATCH 41/41] docs(flags-core): describe exclusive sources, age expiry, and snapshot getDatafile Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WjWjQyvVvunuerU51GvB9M --- .changeset/header-driven-vercel-mode.md | 8 +- .changeset/tidy-flags-cache.md | 2 +- packages/vercel-flags-core/CLAUDE.md | 58 ++++++----- packages/vercel-flags-core/README.md | 96 +++++++++++-------- .../src/controller/normalized-options.ts | 18 ++-- 5 files changed, 105 insertions(+), 77 deletions(-) diff --git a/.changeset/header-driven-vercel-mode.md b/.changeset/header-driven-vercel-mode.md index cc7b93d89..eff03a4d4 100644 --- a/.changeset/header-driven-vercel-mode.md +++ b/.changeset/header-driven-vercel-mode.md @@ -4,10 +4,10 @@ Add a header-driven `vercel` client mode, enabled by default when `VERCEL=1`. Initialization loads provided/bundled definitions; request versions trigger refreshes and an empty cache fetches on its first read. Explicit offline/build behavior is preserved. -Evaluations and `getDatafile()` require a valid positive version header for their own project. Missing, empty, malformed, or unrelated project entries permanently start streaming when enabled, otherwise polling; multiple clients select their sources independently. Concurrent reads share startup and pending HTTP refreshes. Accepted stream updates and valid confirmations cancel superseded refreshes; waiting reads use the confirmed cache, and late responses cannot change cache or authorization state. `getDatafile()` shares lazy initialization and the evaluation resolution path, including header assessment, SWR, blocking refresh, stale-if-error, and source fallback. Initialization alone does not trigger this switch. A cold shared fetch discovers project identity before accepting header evidence; failed discovery starts source fallback. +Evaluations require a valid positive version header for their own project. Missing, empty, malformed, or unrelated project entries permanently start streaming when enabled, otherwise polling; multiple clients select their sources independently. Concurrent reads share startup and pending HTTP refreshes. Accepted stream updates and valid confirmations cancel superseded refreshes; waiting reads use the confirmed cache, and late responses cannot change cache or authorization state. A cold shared fetch discovers project identity before accepting header evidence; a failed cold fetch rejects without switching sources and is retried by the next read. `getDatafile()` remains a snapshot that never starts streaming or polling: it serves cached definitions through the same header checks, stale-while-revalidate, blocking refresh, and stale-if-error, and loads bundled definitions or performs a one-time fetch when the cache is empty. -The controller supplies a source freshness-status callback to the cache and configures one shared fetch callback. HeaderSource keeps version observations; the cache handles serving, background/blocking refreshes, shared work, cancellation, and stale-if-error. The cache owns age and resets it on accepted updates or confirmations, without rewriting `fetchedAt`. Streaming becomes stale after 60 seconds and expires after 90 seconds. Polling becomes stale after its interval plus the 10-second fetch deadline and expires after two intervals plus that deadline (40/70 seconds by default). Stale reads refresh in the background; expired reads block on the shared refresh. Stream pings reset age and clear failures. Polling initialization honors its configured timeout while preserving the pending poll and interval; cached fallback does not renew cache age or stale-if-error. +**Cached definitions now expire by age in streaming and polling mode.** Streaming data is fresh for 60 seconds after the last message; polling data is fresh for its interval plus the 10-second fetch deadline (40 seconds by default). Data then stays stale for `staleWhileRevalidate` seconds (default 10) while reads refresh over HTTP in the background, and expired reads wait for the shared refresh. Data without a known age is served until its source first confirms it. Accepted updates, source responses, valid confirmations, and stream pings reset age without rewriting `fetchedAt`. -Ping timeouts reconnect the stream internally, allowing suspended runtimes to resume without starting polling. Reconnection alone does not renew cache freshness. Connection errors, server closure, and exhausted retries trigger an immediate background poll followed by interval polling. Reads and polling share pending HTTP refreshes; stream recovery stops polling. +Streaming and polling never run at the same time. A stream startup timeout or disconnect keeps the stream reconnecting in the background while reads serve the cache; polling starts only once the stream gives up for good (retries exhausted, 401, or token failure), waiting for its first poll up to `polling.initTimeoutMs`. Ping timeouts reconnect the stream internally, allowing suspended runtimes to resume. If the stream gives up and polling is disabled, reads revalidate over HTTP on the streaming schedule. Shutting down and reinitializing a client rewires its sources and starts with a clean cache and failure deadline. -Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. `staleWhileRevalidate` controls header-driven refreshes; stream/poll freshness follows their update schedules. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse. +Use `staleWhileRevalidate` (default 10) and `staleIfError` (default Infinity) in **seconds**, including fractions. Setting either to `0` disables its stale allowance. Datafiles preserve optional `fetchedAt` epoch-millisecond timestamps across serialization and bundled/provided reuse. diff --git a/.changeset/tidy-flags-cache.md b/.changeset/tidy-flags-cache.md index f68327df5..e19c04b66 100644 --- a/.changeset/tidy-flags-cache.md +++ b/.changeset/tidy-flags-cache.md @@ -2,4 +2,4 @@ "@vercel/flags-core": minor --- -Add `staleIfError` in seconds to bound cached runtime reads after the first consecutive stream/poll failure or stream disconnect. The default `Infinity` preserves unlimited fallback; finite nonnegative durations (including fractional seconds) use existing evaluation defaults and errors after expiry, and `getDatafile()` follows the same allowance. Accepted updates, valid equal-version responses, or matching stream primed revisions reset the allowance. Storing fallback data does not confirm freshness or renew the failure clock. Build/offline behavior and source scheduling remain unchanged. +Add `staleIfError` in seconds to bound cached runtime reads after the first consecutive stream/poll failure or stream disconnect. The default `Infinity` preserves unlimited fallback; finite nonnegative durations (including fractional seconds) use existing evaluation defaults and errors after expiry, and `getDatafile()` follows the same allowance. Any successful source response resets the allowance, including one the version guard rejects as older or for a different project, as does a matching stream primed revision. Storing provided or bundled fallback data does not confirm freshness or renew the failure clock. Build/offline behavior and source scheduling remain unchanged. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 53f8d15fb..4156d9a02 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -137,13 +137,13 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu latest accepted fetch or valid confirmation; unknown/expired cache age blocks for refresh. - Every returned entry passes through `DatafileCache.read()`. Refresh errors use its `staleIfError` allowance; expiry forces blocking recovery on the next newer-header read. -- Reads without a valid positive version for this client’s project (missing, empty, +- Evaluations without a valid positive version for this client’s project (missing, empty, malformed, or unrelated headers) permanently start streaming if enabled, otherwise polling, using the existing startup timeouts. Clients select independently. A cold cache first loads definitions and discovers project identity via a shared HTTP fetch. - The next read checks source availability through the cache assessment. Missing or invalid - cached config versions also produce assessment errors. A failed cold fetch rejects - without switching sources; subsequent reads can retry the fetch. + The next evaluation checks source availability through the cache assessment. Missing or + invalid cached config versions also produce assessment errors. A failed cold fetch rejects + without switching sources; subsequent reads retry the fetch. Concurrent new reads share source startup and pending HTTP refreshes. Accepted stream updates and valid confirmations cancel superseded HTTP work; waiting reads use the confirmed cache, and late responses cannot change failure or authorization state. @@ -151,10 +151,12 @@ Build-step reads are deduplicated: data is loaded once via a shared promise (`bu indicator without starting or clearing a fetch-failure deadline. Unservable data is omitted, allowing the controller to start fallback even after stale-if-error expires. Handover retains cached data before considering seeds. -- `getDatafile()` shares lazy initialization and `resolveData()` with evaluations, including - header assessment, SWR, blocking refresh, stale-if-error, and source fallback. - It only adds response construction and metrics, without evaluation telemetry. - Disabling both stream and polling selects offline mode. +- `getDatafile()` is a snapshot (`resolveSnapshot()`): it never starts streaming or polling + and only shares a source startup already in flight. It serves cached data through the + active source policy (header assessment, SWR, blocking refresh, stale-if-error) and loads + bundled definitions, then a one-time fetch, when the cache is empty. It selects header + mode on an idle client because that needs no network. Disabling both stream and polling + selects offline mode. **Other runtime** (default outside Vercel, or `vercel: false`): 1. **Stream** - Real-time updates via NDJSON streaming, wait up to `initTimeoutMs` @@ -168,11 +170,16 @@ Key behaviors: - When streaming or polling is enabled and data already exists (bundled or provided), `initialize()` still waits for fresh data (stream confirmation or first poll) up to `initTimeoutMs`, then falls back to existing data on timeout - For offline mode with existing data, `initialize()` returns immediately - **Never stream AND poll simultaneously** -- If stream reconnects while polling → stop polling -- If stream disconnects → start an immediate poll (if enabled), then interval polling. - Reads share polling initialization and wait for the poll or its initialization timeout. - The detached disconnect handler catches startup rejection; waiting reads still receive it. - Ping timeouts reconnect quietly without starting polling. +- A stream startup timeout or disconnect keeps the stream reconnecting in the background + (state `degraded`); reads serve the cache with an `unknown` freshness assessment and + start no HTTP work of their own. Only an empty cache performs a blocking fetch. +- Polling starts only when the stream gives up for good (`exhausted` event: retries + exhausted, 401, or token failure). The detached handler shares `activateFallbackSource()` + with waiting reads and catches its rejection. If polling is disabled, `degraded` reads + revalidate over HTTP using the stream's age windows. +- Ping timeouts reconnect quietly without recording a failure. +- `shutdown()` followed by `initialize()` rewires source events and starts with a clean + cache and failure deadline. - Use `buildStep: true` to force static-only mode (e.g., serverless cold starts) - Use `buildStep: false` to force runtime mode (e.g., custom build environments) @@ -287,7 +294,8 @@ When updating tests for new behavior, preserve the strength of existing assertio - Default `initTimeoutMs`: 3000ms - 401 errors abort immediately (invalid SDK key) and reject the init promise, so fallback kicks in without waiting for the stream timeout - A ping timeout reconnects the transport internally without emitting a disconnect or starting polling, including when a suspended runtime resumes. Replacement streams keep a watchdog before their first message. Reconnecting alone does not renew cache age or clear failures; stale/expired reads still refresh through HTTP. -- On connection errors, server closure, or retry exhaustion: state transitions to `'degraded'`, falls back to polling if enabled +- On connection errors or server closure: state transitions to `'degraded'` while the connection loop retries; polling does not start +- On retry exhaustion, 401, or token failure the loop aborts its own controller; `StreamSource` emits `'exhausted'` (its `stop()` clears the fields first so an external stop does not) and the Controller starts polling if enabled - On reconnect: Controller listens for `'connected'` event and transitions back to `'streaming'` - Background stream promises (from init timeout) are `.catch`-ed by the Controller to prevent unhandled rejections when the stream is aborted before receiving data @@ -297,9 +305,9 @@ When updating tests for new behavior, preserve the strength of existing assertio - Default `intervalMs`: 30000ms (30s) - Default `initTimeoutMs`: 3000ms (3s) - Datafile fetches use three total attempts with 100ms and 200ms backoff for network, token, body parsing, and transient HTTP failures (408, 429, and 5xx). Other HTTP errors fail immediately. After exhausted retries, polling emits an error event and waits for the next interval. -- Stops automatically when stream reconnects +- Once polling takes over from an exhausted stream it keeps running for the client's lifetime; the stream is not restarted - `PollingSource` shares the cache's HTTP refresh for initialization, immediate fallback, and scheduled polls. Cache confirmation cancels superseded refreshes for stream evidence; the controller clears them on shutdown. Stopping the poller suppresses errors from its pending work. -- Every transition to polling waits for the first poll up to `initTimeoutMs`, including stream disconnections with retained data. A timeout permits cached fallback subject to stale-if-error without renewing cache age or failure allowance; the pending poll and recurring interval continue. Zero waits for the poll, subject to its ten-second fetch deadline. +- Every transition to polling (configured source, or takeover after the stream is exhausted) waits for the first poll up to `initTimeoutMs`. A timeout permits cached fallback subject to stale-if-error without renewing cache age or failure allowance; the pending poll and recurring interval continue. Zero waits for the poll, subject to its ten-second fetch deadline. - After runtime suspension, delayed intervals resume polling without changing sources. A request pending across suspension can hit its fetch deadline; the interval continues and a later successful poll clears the failure. - `fetchDatafile` owns a ten-second deadline covering token resolution, all attempts and backoff, and body parsing. It settles on timeout or cancellation even when a transport ignores its signal, and preserves the external abort reason. - Retries are enabled by default for every `fetchDatafile` caller: polling, build loading, offline initialization/evaluation, and direct `getDatafile()` fallback. Internal callers can override `maxAttempts`; retry scheduling and deadline handling remain in the fetch helper, independently of source classes and cache policy. @@ -315,7 +323,8 @@ The Controller selects the origin. Initial/fallback snapshots are tagged before `fetchedAt`; provided and bundled data preserves valid finite nonnegative timestamps. The cache seeds its own freshness age from that timestamp; missing/invalid timestamps mean unknown age. Accepted updates and valid confirmations reset cache age without -rewriting the stored `fetchedAt`. Equal/older responses do not replace or retag data. +rewriting the stored `fetchedAt`. Equal/older/mismatched responses do not replace or retag +data, but every successful source response resets age and clears the failure. ### Usage Tracking @@ -353,10 +362,12 @@ with the internal `staleIfErrorMs`, normalized from the public `staleIfError` option in seconds. Evaluations and `getDatafile()` share the same serving boundary. `hasData` and `revision` expose coordination metadata even after expiry, so retained data is not replaced by fallback and stream reconnects can still send -`X-Revision`. `seed()` never clears failure. Accepted source updates or valid -version/revision confirmations clear it; repeated errors/disconnects do not renew +`X-Revision`. `seed()` never clears failure; `clear()` resets it. Every successful +source response (`updateFromSource()`, including rejected versions) and valid +primed/header confirmations clear it; repeated errors/disconnects do not renew the first-error deadline. Stream opening and initialization timeout alone -are not recovery/failure evidence respectively. +are not recovery/failure evidence respectively. Last-resort fetches go through +`updateFromSource()` so they clear failure too. `tryConfirm()` validates version and identity, then delegates to `confirm(source)`. Confirmation owns freshness, recovery, and cancellation: stream evidence cancels @@ -376,9 +387,10 @@ HTTP results update the cache before the shared fetch promise settles. Stream/po evaluations refresh stale data in the background and block on expired data, sharing scheduled HTTP work. New public time windows use seconds; internal normalized durations, cache age, and `fetchedAt` use milliseconds. Polling is fresh through its interval plus -the 10-second fetch deadline and expires after two intervals plus that deadline. -Streaming is fresh through 60 seconds and expires after the 90-second ping timeout. -These windows are independent of the header-only `staleWhileRevalidate` option. +the 10-second fetch deadline; streaming is fresh through 60 seconds (`STREAM_FRESH_MS`). +Both then stay stale for `staleWhileRevalidateMs` before expiring, and both report an +unknown age (`Infinity`) as `unknown` so unconfirmed seeds are served without a refresh. +Each source exposes `staleAfterMs`/`expiresAfterMs` for its `assess()` and its logs. Accepted updates, valid confirmations, and stream pings reset cache age. Pings also clear failures: each connection sends `primed` or a datafile before pings, so they confirm recovery without rewriting `fetchedAt`. diff --git a/packages/vercel-flags-core/README.md b/packages/vercel-flags-core/README.md index 418388f16..cc037e364 100644 --- a/packages/vercel-flags-core/README.md +++ b/packages/vercel-flags-core/README.md @@ -38,19 +38,20 @@ Outside Vercel, pass an SDK key explicitly: `createClient(process.env.FLAGS)`. When `VERCEL=1`, the client defaults to `vercel: true`. Initialization loads provided or bundled definitions without starting a stream or polling. Request version headers indicate when cached definitions need refreshing. Header mode requires a valid positive -version for the client’s own `projectId`. Missing, empty, malformed, or unrelated entries -permanently switch that client to streaming when enabled, otherwise polling. Clients -also switch when cached definitions have no valid positive config version to compare. -Clients with different projects select their sources independently within the same request. -Concurrent reads share that startup and later headers do -not switch the client back. Pending HTTP refreshes remain shared until the stream -delivers current data or confirms the cached version. That confirmation cancels the +version for the client’s own `projectId`. An evaluation whose request carries a missing, +empty, malformed, or unrelated entry permanently switches that client to streaming when +enabled, otherwise polling. Clients also switch when cached definitions have no valid +positive config version to compare. Clients with different projects select their sources +independently within the same request. Concurrent reads share that startup and later +headers do not switch the client back. Pending HTTP refreshes remain shared until the +stream delivers current data or confirms the cached version. That confirmation cancels the superseded refresh, and waiting reads use the confirmed cache; late responses cannot change cache or authorization state. With an empty cache, the first read uses a shared -fetch to load definitions and discover the client’s project. The next read assesses +fetch to load definitions and discover the client’s project. The next evaluation assesses that project’s header entry and starts the stream/poll fallback if it is unavailable. -If the cold fetch fails, the read rejects without switching sources. Fetch failures -use cached data only while stale-if-error permits it; source assessment errors start fallback. +If the cold fetch fails, the read rejects without switching sources, and the next read +retries it. Fetch failures use cached data only while stale-if-error permits it; source +assessment errors start fallback. ```ts const client = createClient(process.env.FLAGS!, { @@ -68,9 +69,11 @@ preserve their original `fetchedAt`; unknown or expired cache age requires a blo refresh when a newer request version arrives. Refresh failures use `staleIfError`. A newer-header read attempts blocking recovery after that failure allowance expires. -`getDatafile()` uses the same lazy initialization and resolution path as evaluations, -including header checks, background revalidation, blocking refresh, stale-if-error, and -source fallback. Concurrent calls share HTTP refreshes across both APIs. +`getDatafile()` is a snapshot: it never starts streaming or polling. It serves cached +definitions through the same header checks, background revalidation, blocking refresh, +and stale-if-error as evaluations, shares pending HTTP refreshes with them, and loads +bundled definitions or performs a one-time fetch when the cache is empty. Only +evaluations switch a client to streaming or polling. Use `vercel: false` to select the stream/poll behavior. Disabling both stream and polling still selects offline mode, and builds retain their existing loading. @@ -92,12 +95,13 @@ deadline; `0` disables cached fallback immediately after failure. Negative values, `NaN`, and negative infinity throw when creating the client. The allowance starts at the first consecutive failure. Repeated errors, -disconnects, and provided or bundled fallback data do not renew it. An accepted -source update, or a finite equal version for the same project and environment, -clears the outage. A stream `primed` message also clears it when its finite numeric -revision and identity match the cached entry. Pings clear failures too: the server -sends `primed` or a datafile before pings on each connection. Opening a connection -alone does not clear a failure. A later failure starts a new allowance. +disconnects, and provided or bundled fallback data do not renew it. Any successful +source response clears the outage: a newer datafile replaces the cached one, while +an equal, older, or differently identified response proves the source is reachable +and leaves the stored definitions in place. A stream `primed` message clears it when +its finite numeric revision and identity match the cached entry. Pings clear failures +too: the server sends `primed` or a datafile before pings on each connection. Opening +a connection alone does not clear a failure. A later failure starts a new allowance. Responses are observed in completion order, with existing version acceptance. After expiry, `evaluate()` returns the caller's default with reason `error`, or @@ -105,28 +109,38 @@ throws the first failure when no default is supplied. `bulkEvaluate()` returns an error result for each requested flag, with its default value when provided. `getDatafile()` follows the same allowance and throws after expiry. The entry is retained for recovery, including its revision for stream reconnection. A clean -stream close records `stream: disconnected` if no earlier failure exists. Ping timeouts -reconnect quietly without recording a failure or starting polling, including after runtime -suspension. Genuine disconnections start an immediate poll, sharing pending read -refreshes, then continue at the configured interval. Every transition to polling -waits for the first poll or `polling.initTimeoutMs`, including reads with cached data. -On timeout, reads follow `staleIfError` while polling continues. A zero initialization -timeout waits for the poll, which still has a ten-second fetch deadline. Stream -recovery stops polling. `getFallbackDatafile()` remains an independent bundled-data export. - -Streaming data becomes stale after 60 seconds and expires after 90 seconds, allowing -one missed 30-second ping before revalidation and matching the stream's ping -timeout. Polling data becomes stale after its interval plus the 10-second fetch -deadline, and expires after two intervals plus that deadline (40 and 70 seconds with -the default 30-second interval). These windows are independent of `staleWhileRevalidate`. -Stale evaluations and `getDatafile()` calls refresh in the background; expired reads -wait for the shared refresh. Refresh failures still follow `staleIfError`. - -Accepted updates and valid confirmations reset cache age without rewriting `fetchedAt`. -Stream pings also reset age and clear any failure. Poll errors feed the shared failure -handler without logging each failed poll. An initialization timeout alone does not -start the failure allowance or reset cache age. It permits cached fallback while -the pending update continues; polling intervals remain active after startup timeout. +stream close records `stream: disconnected` if no earlier failure exists, and the +stream reconnects on its own with backoff while reads keep serving the cache. A +routine reconnect takes about a second, so keep `staleIfError` above the reconnect +delay; `0` or sub-second values fail reads during every reconnect. Ping timeouts +reconnect quietly without recording a failure, including after runtime suspension. + +Streaming and polling never run at the same time. Polling starts only once the stream +has given up for good: its retry budget is exhausted, it receives a 401, or its token +cannot be resolved. A stream startup timeout keeps connecting in the background without +polling. Whenever polling starts, reads wait for the first poll or `polling.initTimeoutMs`; +on timeout, reads follow `staleIfError` while polling continues at the configured +interval. A zero initialization timeout waits for the poll, which still has a ten-second +fetch deadline. If the stream gives up and polling is disabled, reads revalidate over +HTTP on the streaming schedule below. `getFallbackDatafile()` remains an independent +bundled-data export. + +**Cached definitions now expire by age.** Streaming data is fresh for 60 seconds after +the last message, allowing one missed 30-second ping. Polling data is fresh for its +interval plus the 10-second fetch deadline (40 seconds with the default 30-second +interval). After that, data is stale for `staleWhileRevalidate` seconds (10 by default): +evaluations and `getDatafile()` calls serve it and refresh over HTTP in the background. +Once that window passes, the data is expired and reads wait for the shared refresh. +`staleWhileRevalidate: 0` makes reads block as soon as the fresh window ends. Data +without a known age, such as a provided datafile without `fetchedAt`, is served until the +source first confirms it. Refresh failures still follow `staleIfError`. + +Accepted updates, source responses, and valid confirmations reset cache age without +rewriting `fetchedAt`. Stream pings also reset age and clear any failure. Poll errors +feed the shared failure handler without logging each failed poll. An initialization +timeout alone does not start the failure allowance or reset cache age; it permits +cached fallback while the pending update continues. Shutting down and reinitializing +a client starts with a clean cache and failure deadline. ## Evaluation Metrics diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index f536af7b1..53cbaaa96 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -49,18 +49,19 @@ export type ControllerOptions = { /** * Use request version headers instead of streaming or polling at runtime. * Initialization starts no network activity; reads fetch only when needed. - * A read without a version header permanently falls back to stream/poll. + * An evaluation without a version header permanently falls back to stream/poll. * Disabling both stream and polling still selects offline mode. * @default process.env.VERCEL === '1' */ vercel?: boolean; /** - * How long header-driven reads may serve cached data while refreshing in the - * background, measured from its last fetch or matching version header. - * Accepts finite, non-negative seconds, including fractional seconds. - * Set to 0 to always block on header-driven refreshes. - * Streaming and polling use freshness windows based on their update schedules. + * How long reads may serve stale cached data while refreshing in the background. + * Header-driven reads measure from the last fetch or matching version header. + * Streaming data is fresh for 60 seconds after the last message and polling data + * for its interval plus the 10-second fetch deadline; this window follows, after + * which reads wait for the refresh. Accepts finite, non-negative seconds, + * including fractional seconds. Set to 0 to block as soon as data is no longer fresh. * @default 10 */ staleWhileRevalidate?: number; @@ -70,8 +71,9 @@ export type ControllerOptions = { * stream/poll/header failure or stream disconnect. Accepts nonnegative seconds or Infinity. * Fractional seconds are supported. * Zero disables fallback immediately; positive windows include the deadline. - * Accepted updates, matching versions, or matching stream primed revisions - * reset the allowance. Applies to evaluations and getDatafile(). + * Any successful source response, or a matching stream primed revision, resets the + * allowance. A routine stream reconnect takes about a second, so keep this above + * the reconnect delay. Applies to evaluations and getDatafile(). * Build/offline behavior is unchanged. * @default Infinity */