Skip to content
5 changes: 5 additions & 0 deletions .changeset/light-flags-revalidate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@reflag/node-sdk": patch
---

Use ETags when refreshing flag definitions to avoid downloading and recompiling unchanged flags. Custom HTTP clients can opt in by returning the response ETag.
14 changes: 13 additions & 1 deletion packages/node-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,19 @@ This behavior is built in and does not currently require configuration.

Flag definitions include the rules needed to determine which flags should be enabled and which config values should be applied to any given user/company.
Flag definitions are automatically fetched when calling `initialize()`.
They are then cached and refreshed in the background.
They are then cached and refreshed in the background. Ordinary refreshes use
ETags to avoid downloading unchanged definitions. A `304 Not Modified` response
keeps the cached definitions and resets their freshness without recompiling
rules or writing a new fallback snapshot. Initial fetches and refreshes with a
`waitForVersion` target remain unconditional. When a fallback provider is
configured, the SDK retains the ETag only after the snapshot is saved
successfully. A failed or pending save keeps ordinary refreshes unconditional
so persistence can recover, without delaying flag availability.

Custom HTTP clients can enable this optimization by returning the exact ETag
value in `HttpClientResponse.etag` and returning 304 responses with
`body: undefined`. Clients that omit the ETag continue using full fetches.

It's also possible to get the currently in use flag definitions:

```typescript
Expand Down
113 changes: 83 additions & 30 deletions packages/node-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
SDK_VERSION_HEADER_NAME,
} from "./config";
import fetchClient, { withRetry } from "./fetch-http-client";
import { FlagsCache } from "./flags-cache";
import { FlagsCache, type FlagsCacheSnapshot } from "./flags-cache";
import {
createFlagsSyncController,
FlagsSyncController,
Expand Down Expand Up @@ -292,6 +292,7 @@ export class ReflagClient {
private flagsSyncController: FlagsSyncController;
private initializationFinished = false;
private canLoadFlagsFallbackProvider = true;
private flagsFallbackSavePromise = Promise.resolve();
private _initialize = once(async () => {
const start = Date.now();
if (!this._config.offline) {
Expand Down Expand Up @@ -536,10 +537,20 @@ export class ReflagClient {
? "features"
: `features?waitForVersion=${encodeURIComponent(String(waitForVersion))}`;

const res = await this.get<FlagsAPIResponse>(
// A 304 cannot prove that a newer requested version was reached.
// Keep version-targeted fetches unconditional.
const etag =
waitForVersion === undefined ? this.flagsCache.getETag() : undefined;
const response = await this.getResponse<FlagsAPIResponse>(
path,
this._config.flagsFetchRetries,
{ etag },
);
if (response?.status === 304) {
return { notModified: true };
}

const res = response?.body;
const flagStateVersion = res?.flagStateVersion;
if (
!isObject(res) ||
Expand All @@ -554,12 +565,21 @@ export class ReflagClient {
: undefined;
}

void this.saveFlagsFallbackDefinitions(res.features);
this.canLoadFlagsFallbackProvider = false;
return {
if (flagStateVersion < (this.flagsCache.getFlagStateVersion() ?? -1)) {
return undefined;
}

const snapshot: FlagsCacheSnapshot = {
definitions: compileFlagDefinitions(res.features),
flagStateVersion,
};
this.saveFlagsFallbackDefinitions(
res.features,
snapshot,
response?.etag,
);
this.canLoadFlagsFallbackProvider = false;
return snapshot;
},
{
logger: this.logger,
Expand Down Expand Up @@ -627,23 +647,33 @@ export class ReflagClient {
}
}

private async saveFlagsFallbackDefinitions(flags: FlagAPIResponse[]) {
const provider = this._config.flagsFallbackProvider;
if (!provider) {
return;
}

try {
await provider.save(
this._config.flagsFallbackProviderContext,
createFlagsFallbackSnapshot(flags),
);
} catch (error) {
this.logger.error(
"flagsFallbackProvider: failed to save flag definitions",
error,
);
}
private saveFlagsFallbackDefinitions(
flags: FlagAPIResponse[],
snapshot: FlagsCacheSnapshot,
etag?: string,
) {
// Serialize background writes so an older save cannot overwrite a newer one.
this.flagsFallbackSavePromise = this.flagsFallbackSavePromise.then(
async () => {
try {
const provider = this._config.flagsFallbackProvider;
if (provider) {
await provider.save(
this._config.flagsFallbackProviderContext,
createFlagsFallbackSnapshot(flags),
);
}
// Enable revalidation only after saving. A late save updates only its
// own snapshot, never a replacement or destroyed cache.
snapshot.etag = etag;
} catch (error) {
this.logger.error(
"flagsFallbackProvider: failed to save flag definitions",
error,
);
}
},
);
}

/**
Expand Down Expand Up @@ -1180,18 +1210,41 @@ export class ReflagClient {
* @returns The response from the server.
* @throws An error if the path is invalid.
**/
private async get<TResponse>(path: string, retries: number = 3) {
private async getResponse<TResponse>(
path: string,
retries: number = 3,
revalidation?: { etag?: string },
) {
ok(typeof path === "string" && path.length > 0, "path must be a string");

try {
const url = this.buildUrl(path);
const etag = revalidation?.etag;
const headers = etag
? { ...this._config.headers, "If-None-Match": etag }
: this._config.headers;
let recoveryAvailable = Boolean(revalidation && !etag);
const request = async (requestHeaders: Record<string, string>) => {
const response = await this.httpClient.get<
TResponse & { success: boolean }
>(url, requestHeaders, this._config.fetchTimeoutMs);
this.logger.debug(`get request to "${url}"`, response);
return response;
};

return await withRetry(
async () => {
const response = await this.httpClient.get<
TResponse & { success: boolean }
>(url, this._config.headers, this._config.fetchTimeoutMs);
let response = await request(headers);
if (response.status === 304 && etag) {
return response;
}

this.logger.debug(`get request to "${url}"`, response);
// An unconditional definition request should not receive a 304.
// Recover once before falling back to normal error handling.
if (response.status === 304 && recoveryAvailable) {
recoveryAvailable = false;
response = await request(this._config.headers);
}

if (
!response.ok ||
Expand All @@ -1202,8 +1255,7 @@ export class ReflagClient {
`invalid response received from server for "${url}": ${JSON.stringify(response.body)}`,
);
}
const { success: _, ...result } = response.body;
return result as TResponse;
return response;
},
(error) => {
this.logger.warn("failed to fetch flags, will retry", error);
Expand Down Expand Up @@ -1709,9 +1761,10 @@ export class ReflagClient {
}

const query = params.toString();
const res = await this.get<EvaluatedFlagsAPIResponse>(
const response = await this.getResponse<EvaluatedFlagsAPIResponse>(
`features/evaluated${query ? `?${query}` : ""}`,
);
const res = response?.body;

if (key) {
const flag = res?.features[key];
Expand Down
7 changes: 4 additions & 3 deletions packages/node-sdk/src/fetch-http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,16 @@ const fetchClient: HttpClient = {
signal: AbortSignal.timeout(timeoutMs),
// We must use no-cache to avoid services such as Next.js from caching the response indefinitely.
// We also can't use no-store because of Next.js error withRetry https://github.com/vercel/next.js/discussions/54036.
// We also have local caching in the SDKs, so we don't need to cache the response.
// The SDK caches definitions and explicitly revalidates them using ETags.
cache: "no-cache",
});

const json = await response.json();
const body = response.status === 304 ? undefined : await response.json();
return {
ok: response.ok,
status: response.status,
body: json as TResponse,
body: body as TResponse | undefined,
etag: response.headers.get("etag") ?? undefined,
};
},
};
Expand Down
Loading
Loading