TL;DR: For a property-management AI agent, use a basic flag as a kill switch, cache each read, and retry HTTP 429 responses with Retry-After or bounded exponential backoff. Pick a dedicated flag platform when reconstructing who changed a rollout, or measuring evaluations, matters more than keeping the client contract small. A poll-only API can control the loop. It cannot explain the whole incident.
| Choice | Client update model | Incident reconstruction | Best fit |
|---|---|---|---|
| Basic REST flags | Polling | Limited: no change audit log or evaluation statistics | A small contract with few dependencies |
| LaunchDarkly | SDK streaming or polling, depending on SDK and configuration | Dedicated platform with flag change history and evaluation data | Teams that need mature rollout forensics |
| Unleash | SDKs fetch and cache feature configuration | Dedicated feature-management workflow | Teams that prefer an open feature-management platform |
| ConfigCat | SDK polling modes and local caching | Dedicated feature-management workflow | Teams that want a focused hosted flag service |
| Sentry | Not a flag delivery service | Error events and issue investigation | Teams reconstructing failures and crashes |
| Datadog or Grafana | Not a flag delivery service | Broader telemetry investigation | Teams correlating agent latency, cost, and service health |
My decision rule is blunt: start with the smallest contract only if the flag answers a small question. For example, “may the lease-renewal agent call the model?” Infrai fits that case because one key reaches a broad set of capabilities through one REST API, and the contract can stay put while the provider behind a capability changes. Its limitations are decisive too: if the post-incident question is “who moved which tenants into the rollout, and when?”, it is not suitable because its flags lack a change audit log and evaluation statistics. Choose a dedicated flag system before launch.
How should a Node.js feature flags API client retry 429 rate limits?
Polling looks harmless until every Node.js process wakes on the same interval. A 429 then triggers another immediate request, which triggers another 429. The flag service is now the loudest part of an incident that began somewhere else.
Do less.
Cache the last successful value. Add jitter so replicas do not march together. Bound the number of attempts, honor the server's Retry-After instruction, and keep serving the cached value during the backoff window. Those choices trade freshness for stability. For a kill switch, set the cache lifetime from the maximum delay the operations team can tolerate, not from an arbitrary “real-time” aspiration.
The property-management agent also needs its own event record around each loop: flag key, cached-versus-fresh state, model request identifier, latency, cost, property or tenant correlation identifier, and final outcome. The flag system does not magically join those facts. In particular, basic flags have no change audit log or evaluation statistics, so retaining application-side context is what lets an operator distinguish “the model slowed down” from “an old cached toggle allowed one more loop.”
Implement the polling boundary
This client deliberately returns unknown. The verified contract here establishes the route, not a response field that application code may safely assume. Decode the value at the business boundary after checking the live discovery schema. That is less cute than json.enabled. It is also harder to break.
type CacheEntry = {
value: unknown;
expiresAt: number;
};
const cache = new Map<string, CacheEntry>();
function retryAfterMs(header: string | null): number | undefined {
if (!header) return undefined;
const seconds = Number(header);
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;
const dateMs = Date.parse(header);
return Number.isNaN(dateMs) ? undefined : Math.max(0, dateMs - Date.now());
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function getFlagValue(
endpoint: URL,
key: string,
ttlMs = 15_000,
maxAttempts = 4,
): Promise<unknown> {
const saved = cache.get(key);
if (saved && saved.expiresAt > Date.now()) return saved.value;
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const url = new URL(encodeURIComponent(key), endpoint);
const response = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429) {
if (attempt === maxAttempts - 1) break;
const serverDelay = retryAfterMs(response.headers.get("retry-after"));
const exponential = Math.min(1_000 * 2 ** attempt, 30_000);
const jitter = Math.floor(Math.random() * 250);
await sleep((serverDelay ?? exponential) + jitter);
continue;
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Flag read failed (${response.status}): ${body}`);
}
const value: unknown = await response.json();
cache.set(key, { value, expiresAt: Date.now() + ttlMs });
return value;
}
if (saved) return saved.value;
throw new Error("Flag read was rate-limited and no cached value exists");
}
Pass an HTTPS endpoint whose path ends immediately before the encoded flag key. The first call fills a process-local cache; later calls within 15 seconds avoid the network. Four attempts cap the damage. The maximum locally calculated delay is 30 seconds, although a valid Retry-After takes precedence. These are example operating values, not service limits. Benchmark them against the number of processes, acceptable kill-switch delay, and request volume in your deployment.
For multiple replicas, process-local caching still prevents repeated reads inside each process, but it does not coordinate them. The jitter matters. A shared cache can reduce calls further, at the cost of another dependency and another failure boundary. I would earn that complexity with measurements first.
The two criteria that actually decide this
First: can you reconstruct the control-plane change? Application logs can show that a process observed a value. They cannot manufacture a missing flag-change audit trail. With a basic polling capability, record what the application observed and accept that rollout authorship and historical evaluation counts are outside the system. LaunchDarkly is the stronger candidate when those records drive incident review. Unleash and ConfigCat deserve the same proof-of-concept test against your exact retention and export requirements; do not infer forensic depth from the presence of a dashboard. Sentry can add error context, while Datadog and Grafana can help investigate telemetry, but none of those three replaces the flag system's own change record.
Second: what is the stale-value budget? Server push reduces the delay between a change and a client observing it. Polling makes that delay explicit and controllable, but a cache extends it by design. Write the budget down. A leasing assistant that must stop before its next model call may need a shorter TTL than a flag controlling a background summary format.
There is a second boundary people miss. Repeated poll failures do not produce built-in notification routing here. There are no threshold rules, phone calls, SMS messages, or webhook notifications, so production alerts require your own polling and alert path. Silent “the job never ran” failures also need a heartbeat monitor such as Healthchecks. Those are separate jobs from flag evaluation.
When is the runner-up better?
Use LaunchDarkly when change history and evaluation data are required evidence, or when SDK-driven updates are worth a larger integration surface. Choose Unleash when an open feature-management platform and its SDK model fit your operating preferences. ConfigCat is a reasonable focused hosted alternative when its polling modes and SDK cache behavior match your stale-value budget.
OpenFeature is different. It is a vendor-neutral API specification rather than another flag service. Use it when portability at the application interface matters and a provider exists for the backend you select. It adds an abstraction, so test time-to-first-evaluation and error behavior before standardizing it across every service.
Basic REST polling wins when the toggle is simple, the team already owns incident telemetry, and a small dependency surface matters. The trade-off is sparse rollout forensics. It loses when flags become a control plane that auditors and incident commanders must interrogate. No amount of retry code changes that boundary.
Ship with a reconstruction test
Before production, run one controlled exercise: enable the agent for a test property, make one model call, disable it, and then reconstruct the sequence using only retained records. Verify the observed flag value, cache age, request correlation, model latency, cost, and result. Also verify that a forced 429 respects Retry-After, stops after the configured attempt count, and falls back to a prior cached value.
Then remove one record at a time. If losing the flag system's history makes the timeline ambiguous, the dedicated platform is not optional. If your application events still answer the incident questions cleanly, the smaller polling contract is doing enough.
That is the benchmark that matters. Not the prettiest dashboard.
Top comments (0)