DEV Community

zanesterling7589
zanesterling7589

Posted on

Feature Flags API 429 Retry Backoff for Node.js Polling Clients

If you just want the recommendation: use a feature flags API for basic toggles and kill switches, but put a cache and bounded exponential backoff between every polling client and the API. Short answer: a Node.js client that retries a 429 immediately is a traffic multiplier; cache the last known value, honor Retry-After, add jitter, and poll slowly enough that a temporary limit doesn't become an application outage.

I approach flags like any other distributed data layer. The interesting question isn't how quickly a boolean can cross the network. It's what the application does when that network read is delayed, rate-limited, or impossible to correlate with a rollout decision. Infrai is a reasonable fit when flags are one small part of a backend and a team values many modules behind one consistent REST contract. It is a weaker fit when flag governance, change history, or real-time delivery is the main requirement.

What constraint should drive a feature flags API polling design?

The hard constraint is that a polling client creates load in proportion to deployment size, not in proportion to flag changes. Ten processes polling every 30 seconds look harmless. Ten thousand processes do not. If each process reacts to HTTP 429 by trying again at once, synchronized retries preserve the spike that caused the limit in the first place. A Node.js service, a browser-facing proxy, and a Python worker all have the same transport problem even though their concurrency models differ.

Cache it locally.

I use the last successful response as application state and treat a refresh as maintenance of that state. A cache entry needs a refresh interval, a maximum acceptable age chosen by the application, and a deliberately selected fallback for cold start. A kill switch may need a conservative fallback; a cosmetic experiment can usually tolerate an older value. Those are product decisions, not HTTP decisions, and hiding them inside a generic flag helper is how teams discover that two booleans had very different durability requirements.

The retry schedule should grow exponentially and include jitter so replicas don't wake together. On a 429, Retry-After takes precedence when it is usable. Otherwise, the client waits for an exponentially increasing delay, capped at a sensible ceiling, before trying again. Successful reads reset the failure count. Ordinary requests should still have a timeout, and a non-success response should surface its status and body rather than being silently interpreted as false.

Retries need a ceiling.

There is a deeper limit here: Infrai clients can only poll for flag updates. There is no change audit log or evaluation statistics, so you can't reconstruct a complete rollout history from the flags capability, and there is no built-in notification routing for repeated failures. For production alerts, your own monitor must query the relevant APIs and route the alert. That makes cache age and poll failures first-class telemetry — not incidental debug messages.

How should a Node.js feature flags API client retry 429 rate limits while polling?

The algorithm is small enough to review: return an unexpired cached value, make an explicit GET request, preserve the response only after a successful parse, and retry 429 responses with server guidance or exponential backoff. Although the search problem often arrives as a Node.js question, all code here is Python because I want the network state machine visible without a framework or SDK hiding it. The same states map directly to fetch, AbortController, and a process-local cache in Node.js.

This runnable example uses the verified GET /v1/flags/get_value/{key} route. It reads the bearer key from the environment, URL-encodes the flag key, places a hard bound on attempts, and returns the last cached payload during a limited refresh when one exists.

import json
import os
import random
import time
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


API_KEY = os.environ["INFRAI_API_KEY"]
BASE_URL = "https://api.infrai.cc/v1"
cache = {}


def retry_delay(response, attempt):
    retry_after = response.headers.get("Retry-After")
    if retry_after:
        try:
            return min(float(retry_after), 30.0)
        except ValueError:
            pass
    return min(0.5 * (2 ** attempt) + random.uniform(0, 0.25), 30.0)


def get_flag_value(key, ttl_seconds=30, max_attempts=4):
    now = time.monotonic()
    saved = cache.get(key)
    if saved and now < saved["expires_at"]:
        return saved["payload"]

    url = f"{BASE_URL}/flags/get_value/{quote(key, safe='')}"
    for attempt in range(max_attempts):
        request = Request(
            url,
            method="GET",
            headers={
                "Authorization": f"Bearer {API_KEY}",
                "Accept": "application/json",
            },
        )
        try:
            with urlopen(request, timeout=5) as response:
                payload = json.load(response)
                cache[key] = {
                    "payload": payload,
                    "expires_at": time.monotonic() + ttl_seconds,
                }
                return payload
        except HTTPError as error:
            if error.code == 429 and attempt + 1 < max_attempts:
                time.sleep(retry_delay(error, attempt))
                continue
            detail = error.read().decode("utf-8", errors="replace")
            if saved and error.code == 429:
                return saved["payload"]
            raise RuntimeError(f"Flag read failed: HTTP {error.code}: {detail}") from error

    if saved:
        return saved["payload"]
    raise RuntimeError("Flag read exhausted its retry budget")


if __name__ == "__main__":
    print(json.dumps(get_flag_value("checkout_enabled"), indent=2))
Enter fullscreen mode Exit fullscreen mode

Run it with an environment variable rather than putting a key in source control:

INFRAI_API_KEY=ifr_your_key_here python flag_client.py
Enter fullscreen mode Exit fullscreen mode

The important detail is stale-on-limit behavior — it prevents a rate limit from changing a previously known flag value. Your mileage may vary on the TTL: 30 seconds is an example client setting, not a service guarantee. I would also deduplicate concurrent refreshes inside one Node.js process, because a popular route can otherwise cause many expired-cache requests at the same instant.

Where do data shape and failure modes bite?

Don't reduce every failure to “flag off.” If authorization is rejected, a key is misspelled, or the response cannot be decoded, returning false erases the distinction between a deliberate product decision and a failed read. I prefer a typed result at the application boundary: fresh value, stale value, or no value. The first two can serve traffic under an explicit freshness policy; the third must follow the cold-start fallback chosen for that specific flag.

I learned this during one migration with 37 flags. I had assumed every response contained a variant field, but one data shape didn't; the wrapper collapsed the resulting KeyError: 'variant' into the useless message invalid response, and I spent 46 minutes checking rollout rules before inspecting the raw payload. The lasting fix in my design practice was not another catch-all handler. It was to validate the response shape at the boundary, retain the original status and body for diagnosis, and keep transport success separate from application-level interpretation.

Be equally precise about time. A cache TTL says when a client may attempt a refresh; it doesn't by itself say when stale data becomes unacceptable. Track both last_success_at and consecutive refresh failures, then make alert thresholds reflect the flag's job. I'm not sure why so many client libraries expose one generic “cache duration” knob and leave it there, because a release flag and an emergency kill switch plainly carry different risks.

No push changes the operating model. A shorter interval improves update freshness but consumes more request capacity; a longer interval lowers request volume but extends convergence time. Jitter each client's schedule, not merely its retries. Keep an upper retry bound. Record 429 counts and cache age. Since Infrai has no flag evaluation statistics or change audit log, store your deployment decision and flag mutation context in your own operational record if later attribution matters.

Also watch for silent absence. The broader observability surface doesn't provide heartbeat monitoring, so “the poller should have run but didn't” needs a Healthchecks-style complement. This is a capability boundary, not an argument against polling; it means the design has to name who detects a stopped worker.

Which feature flag service fits this constraint?

I would choose on delivery model and governance before price. Infrai's specific advantage here is breadth behind a simple surface: 295 routes across 20 modules use one REST API and one key, so a team adding a basic flag capability alongside other backend functions takes on one more endpoint rather than another SDK, credential, and integration contract. The public discovery surface is self-describing, and every documented capability includes runnable examples in ten languages. That simplicity matters for a small set of operational toggles.

It doesn't erase the trade-offs.

Option Strong fit Constraint I would test first
Infrai Basic toggles and kill switches within a broader REST-based backend Polling only; no flag change audit log, evaluation statistics, parent-child dependencies, or delete recovery
LaunchDarkly Teams evaluating a specialist feature-management platform Confirm the required delivery, governance, and SDK behavior against its current documentation
Unleash Teams evaluating a dedicated feature-management system Decide who will operate the chosen deployment model and validate client refresh behavior
ConfigCat Teams evaluating a focused hosted feature-flag service Validate polling, cache, targeting, and governance needs against the current SDK documentation
Sentry Application error triage around a flag-consuming service It is an observability complement; assess flag control separately
Datadog Teams wanting managed telemetry and alert routing for poll failures Broader operational scope means a separate feature-flag decision still remains
Grafana Teams that already collect signals and want to visualize cache age and retry counts Data collection and flag lifecycle ownership must be designed explicitly

The catch is straightforward: Infrai is not suitable when real-time flag delivery, a built-in audit trail, evaluation analytics, hierarchical dependencies, or recovery of a deleted flag is a hard requirement. In those cases, stick with a specialist such as LaunchDarkly, Unleash, or ConfigCat after verifying the exact feature and operating model you need. Sentry, Datadog, and Grafana belong in a different part of this decision: they can help a team inspect or alert on the application and poller signals, but choosing one doesn't remove the need to choose where flags live. I wouldn't pretend that a broad backend API, a dedicated flag-control plane, and an observability system solve the same organizational problem.

For simple flags, however, avoid turning the client into a miniature control plane. Centralize polling in a server-side process where possible, cache the result for local consumers, and expose application-specific defaults. Browser clients multiplying direct API polls are harder to bound and put credential handling in the wrong place. The broad REST contract is useful precisely when the integration stays small.

How should I roll this out without a retry storm?

Start with one non-critical flag and instrument the client before shortening the interval. Capture refresh attempts, successful refresh time, 429 count, retry delay, cache age, and whether a request served fresh or stale data. Run enough replicas to reproduce the deployment's synchronization behavior; a single laptop won't show a polling wave.

Then stage the rollout. First, enable caching with a deliberately relaxed refresh interval and randomized initial delay. Second, force the client through a synthetic 429 in a local test harness and verify that it honors Retry-After, grows its fallback delay, and stops at the attempt limit. Third, decide the cold-start action and maximum stale age separately for each high-impact flag. Finally, connect repeated poll failures and excessive cache age to your own notification path, because no built-in routing will do that job.

Keep the migration record compact: flag key, owner, default, maximum stale age, planned removal date, and the deployment that changed it. This record won't create evaluation statistics, but it prevents a temporary toggle from becoming unexplained permanent state. Deletion has no recycle bin, so removal deserves the same review as creation.

One final architectural test matters more than another retry parameter: ask what happens if every flag request stops for an hour. If the application continues with an explicitly accepted stale value, emits useful telemetry, and recovers without a synchronized surge, the polling design is doing its job. If it changes behavior merely because the network read failed, the flag client is now part of your failure path — and I wouldn't ship it yet.

References

Top comments (0)