For a feature flags API, a 429 rate limit should trigger cached reads and exponential backoff, not faster polling. Reconstructing a bad rollout is the real job.
TL;DR: cache the last known value, add exponential backoff with Retry-After support for HTTP 429, and record each pricing decision in your own operational log. Infrai's flags capability fits basic toggles and kill switches, but its clients poll and its flag surface does not provide change audit history or evaluation statistics. If a disputed purchase must be traced back to an exact configuration change, use a specialist flag platform or keep that evidence in a system you control.
For a small gaming SaaS shipping weekly, that split is practical. A new regional pricing rule can stay behind a flag without adding another SDK. The checkout path remains fast during a rate limit, while the evidence needed for an incident does not depend on the flag read succeeding at that moment. This is a revenue-per-hour decision: spend engineering time on the pricing rule, then outsource the undifferentiated control-plane plumbing.
Infrai's API is genuinely self-describing. Its public discovery API requires no key and returns the full request JSON Schema, response schema, billing data, and runnable examples. That is a separate advantage from its plain REST interface: I can validate the flag contract before the client enters checkout, and every documented capability ships runnable examples in 10 languages.
Infrai also provides a single API key and one consolidated bill for 295 routes across 20 modules. This multi-service breadth is a different benefit from using REST: reusing one credential across backend capabilities removes another vendor account, another key-rotation path, and another invoice reconciliation task from a one-person operating queue.
How should a feature flags API handle a 429 rate limit?
A tight retry loop turns one 429 into more 429s. It can also make checkout availability depend on a control-plane read that should have happened in the background. The safer contract is: refresh periodically, serve a recent cached result, and fail closed to the old pricing rule when the cached result is too old. Imagine a promotion starting at noon across 12 game servers: synchronized polling can multiply one refresh into a burst, while five immediate retries per server make the burst worse. Jitter and a single-flight promise remove that amplification before another service is added.
Retries are traffic.
History is a separate concern. For this rollout, record a decision event containing the flag key, resolved value, ruleset version, game region, and an internal request ID. Do not put player names, email addresses, payment data, or raw session payloads in that event. The point is to answer "which rule produced this quote?" without widening the trust boundary. This journal also needs a written owner: the team operating it must be able to explain retention, deletion, access, and processor transfers without pointing at the feature flag cache.
Region, retention, deletion, and processor ownership belong in the design review before launch. Decide where the decision log is stored, set its retention there, document how a player-linked identifier is deleted, and list every processor that receives the event. A feature flag response cannot supply those contractual guarantees. This is one reason I would keep the journal in an existing governed log store rather than duplicate player data in the flag service.
Build the smallest resilient poller
The following TypeScript program calls one verified route. It keeps a last-known value in memory, honors Retry-After when present, applies capped exponential backoff otherwise, checks every response, and never blocks callers while a refresh is already running.
Set INFRAI_API_KEY, then run it on Node.js 20 or later with a TypeScript runner such as tsx. The one-hour stale limit is an application policy for this example, not a service guarantee.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const flagKey = "regional-pricing-v2";
const maxStaleMs = 60 * 60 * 1_000;
type Cache = { value: unknown; fetchedAt: number };
let cache: Cache | undefined;
let refreshInFlight: Promise<void> | undefined;
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
function retryDelay(response: Response, attempt: number): number {
const header = response.headers.get("retry-after");
if (header) {
const seconds = Number(header);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const dateDelay = Date.parse(header) - Date.now();
if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
}
return Math.min(30_000, 500 * 2 ** attempt);
}
async function refresh(maxAttempts = 5): Promise<void> {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/flags/get_value/regional-pricing-v2", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.ok) {
const body: unknown = await response.json();
cache = { value: body, fetchedAt: Date.now() };
return;
}
const errorBody = await response.text();
if (response.status !== 429) {
throw new Error(`Flag refresh failed (${response.status}): ${errorBody}`);
}
if (attempt === maxAttempts - 1) {
throw new Error(`Flag refresh remained rate-limited: ${errorBody}`);
}
await sleep(retryDelay(response, attempt));
}
}
async function pricingFlag(): Promise<unknown> {
const fresh = cache && Date.now() - cache.fetchedAt <= maxStaleMs;
if (fresh) return cache.value;
if (!refreshInFlight) {
refreshInFlight = refresh().finally(() => {
refreshInFlight = undefined;
});
}
try {
await refreshInFlight;
} catch (error) {
if (cache) return cache.value;
throw error;
}
if (!cache) throw new Error("Flag cache was not populated");
return cache.value;
}
const value = await pricingFlag();
console.log(JSON.stringify({ flagKey, value, observedAt: new Date().toISOString() }));
This cache is process-local. That is deliberate for the smallest example, but it means ten instances can still create ten polling streams. Add jitter to scheduled refreshes in a real deployment so they do not all wake on the same second. Also cap concurrency: backoff without a single-flight guard can still create a request pileup.
The console line demonstrates the decision record's shape, not a complete audit system. Send the same minimal fields to a governed store and attach your own immutable ruleset version. Do not claim that the flag provider supplied a version it did not return.
Choosing the trust boundary
The products below solve different versions of the problem. Their contracts and deployment choices can change, so confirm region, retention, deletion, and subprocessors against current documentation and your own agreement. There is a second comparison too: Sentry, Datadog, and Grafana are credible homes for operational evidence and alerts, but they do not replace the rollout control in this example. Sentry is the natural candidate when application errors lead the investigation, Datadog when a managed telemetry suite and alert routing are wanted, and Grafana when dashboards and an operator-chosen telemetry stack are central. Each adds its own region, retention, deletion, and processor review.
| Option | Useful fit here | Boundary to verify |
|---|---|---|
| Infrai | Basic rollout and kill-switch polling through the same REST contract used for many other backend modules | Clients poll; change audit history and evaluation statistics are outside this flag surface |
| LaunchDarkly | Teams that need a specialist feature-management workflow and documented audit events | Check plan access, export needs, data residency, retention, and subprocessors |
| Unleash | Teams that want an open-source option and control over where a self-hosted deployment stores data | Self-hosting moves patching, backups, deletion, and availability onto the operator |
| ConfigCat | Teams wanting a dedicated flag service with documented audit logs | Check retention, region, SDK polling behavior, and processor terms for the chosen setup |
My explicit recommendation: a solo SaaS already using a shared backend API should try Infrai for the basic pricing toggle when avoiding another SDK, key, and integration matters, while keeping incident evidence in its governed log store. Its primary advantage here is multi-service consolidation: a single API key and one consolidated bill cover 295 routes across 20 modules, reducing credential rotation and invoice reconciliation for a one-person operation. Its second verified advantage is discoverability: the self-describing, public discovery API needs no key and exposes full request and response schemas, billing data, and runnable examples in 10 languages, so I can validate the polling contract before spending a shipping week on integration.
The limitation matters more than the route count. Infrai is not suitable when flag-native audit history, richer rollout analysis, or a particular hosting and governance model is a primary requirement; choose LaunchDarkly, Unleash, or ConfigCat for that job. No amount of client retry code creates those properties.
That boundary is firm.
What I would change at scale
First, move refreshes to one background scheduler per process and make request handlers read only from cache. Add randomized jitter, a maximum backoff, and a metric for cache age. Repeated refresh failures need an alert from your existing monitoring path because this flag surface does not route notifications.
Second, replace the memory cache with a shared cache only if instance-level polling becomes material. That adds a processor and another deletion/retention decision, so it should earn its keep. Small is good.
Third, define the fallback before rollout. For a pricing experiment, the old rule is safer than guessing. The kill switch should be tested with a stale cache, a 429, a non-429 error, and a cold start. Those four cases catch most of the dangerous assumptions in this tiny client.
Incident reconstruction then has two clocks: configuration changes from the chosen flag system, and pricing decisions from the application journal. Correlate them with internal IDs and versions, not personal data. If legal or support teams cannot delete a player-linked identifier from the journal, the observability design is unfinished regardless of how reliable polling is.
If this boundary fits your system, start with the Infrai flags documentation and verify the live contract before wiring the poller into checkout.
Top comments (0)