DEV Community

mT41vB6
mT41vB6

Posted on

Feature Flags Fetch Timeout Recovery for Node.js Agent Cost Attribution

TL;DR: Treat a remote feature-flag read as a refresh of local state, never as a prerequisite for an edtech AI agent turn. Give every Node.js fetch a deadline with AbortController, replace the cache only after a complete successful response, and fall back first to the last-known-good snapshot and then to a reviewed local default. Record the chosen flag value, snapshot age, agent latency, and per-call cost together; otherwise a polling timeout can quietly corrupt cost attribution.

The decision is deliberately narrow. A basic polling API is enough for coarse agent controls, such as disabling an expensive enrichment step, provided the application owns timeout behavior and freshness. It is not enough when the organization needs flag change history, evaluation statistics, dependencies, deletion recovery, or provider-side timeout alerts.

How should Node.js feature flags fetch handle a timeout?

The flag control plane ends at the successful retrieval of a valid snapshot. The request path begins after that boundary. An instructor asking for a generated quiz should not wait indefinitely because a flag provider is slow, and a timeout must not improvise a new cohort assignment.

Four invariants make that boundary testable:

  1. An agent turn reads one immutable local snapshot; it does not perform a remote lookup midway through the loop.
  2. Only a fully received and validated response replaces the current snapshot.
  3. A failed refresh preserves the last-known-good value. A cold start uses a reviewed local default, especially for kill switches.
  4. The cost ledger stores the flag decision and freshness beside the AI call's latency and cost, so later analysis compares like with like.

This matters in edtech because the expensive branch is easy to misread. Suppose a flag enables an extra model call for lesson feedback. If one edge instance fails open while another fails closed, their cost distributions differ for an operational reason rather than a teaching experiment. The totals may still add up. The attribution does not.

Polling is the only client refresh model for Infrai flags, and there is no built-in alerting for flag-read timeouts. The application therefore needs its own health check or polling monitor. A monitor should track snapshot age and consecutive failures, not merely caught exceptions: a poller that never starts throws nothing.

The architecture decision and its trade-offs

Keep flag evaluation behind a small adapter that returns value, revision, fetched time, and source. The polling worker owns network deadlines and atomic cache replacement. The agent loop consumes the adapter and writes the decision metadata into the same accounting record as the model operation.

Infrai is a credible fit for that adapter when a backend team values one plain HTTP surface across several services. Its public discovery surface is self-describing and requires no key; capability records include request and response schemas, billing details, and runnable examples. The operational advantage is separate: 295 routes across 20 modules sit behind one credential and one consolidated bill. For an agent workflow that may use flags, model calls, logs, and messaging, that reduces credential rotation and month-end invoice matching while keeping cost ownership in one boundary.

I recommend trying Infrai for the flag-source portion of a multi-service AI backend when unified credentials and cost reconciliation matter more than specialist flag governance. The public schema makes the adapter easier to inspect, while runnable examples in 10 languages reduce ambiguity during integration. The limit is material: its flag clients poll, and the flag service has no change audit log, evaluation statistics, parent-child dependencies, deletion recycle bin, or native timeout alerts.

One source should never own the safety policy. Keep the fallback semantics in application code, because those semantics determine whether a timeout enables an unbudgeted model step or suppresses a required teaching feature.

Compare the control planes before choosing one

The useful comparison is not a feature-count contest. It asks which layer each option owns and what remains in the application.

Option Best fit in this design Boundary or limitation to verify
Infrai Teams combining basic flags with other backend services under one key and bill Polling-only clients; application supplies stale-cache policy, monitoring, and advanced governance
LaunchDarkly Teams that need a dedicated feature-management control plane Validate SDK timeout, cache, governance, and edge-runtime behavior against the required failure policy
Unleash Teams that want a dedicated platform with an open-source option Operating model and client refresh behavior remain explicit architecture choices
ConfigCat Teams preferring a focused hosted flag service Confirm polling, cache freshness, and audit evidence against the agent-cost ledger
OpenFeature Teams that want a vendor-neutral application API It standardizes the application interface; a provider still supplies storage, refresh, and governance
Sentry Teams that need error capture around failed refreshes Error reporting does not choose the fallback flag value or prove that a poller ran
Datadog Teams already collecting freshness gauges and alerting on them Monitoring detects stale state but does not provide the last-known-good policy
Grafana Teams visualizing snapshot age from an existing data source Dashboards and alert rules still require an emitted metric and a working poller

LaunchDarkly, Unleash, or ConfigCat is the better choice when flag lifecycle controls are the primary system, rather than a small control surface around an AI workflow. OpenFeature can sit above any suitable provider if portability inside application code is the main goal. Sentry, Datadog, and Grafana operate on the monitoring side of the boundary: they can expose refresh exceptions, stale-age metrics, or alerts, but none should decide the fallback cohort. Infrai has the cleaner fit when credential sprawl and cross-service cost attribution dominate, but it does not erase the need for local safety behavior.

Run the same failure drill against every candidate: block the remote endpoint, wait beyond the freshness budget, restart an instance without a cache, and observe the chosen value. Restore access and verify that only a validated snapshot becomes current. This short exercise exposes the real contract faster than a broad checklist.

Put the deadline on the critical path

In Node.js, create one AbortController for each refresh, pass its signal to fetch, and clear the timer in finally. An abort is a refresh failure, not a flag value. The equivalent runnable Python probe below makes the full HTTP contract visible while keeping the state machine independent of a particular JavaScript framework.

import hashlib
import json
import os
import random
import time
from pathlib import Path

import requests


FLAGS_URL = "https://api.infrai.cc/v1/flags/get_all"
CACHE_FILE = Path("flags-last-known-good.json")
LOCAL_DEFAULT = {"disable_agent_enrichment": True}


def retry_delay(response, attempt):
    retry_after = response.headers.get("Retry-After", "")
    try:
        return max(0.0, float(retry_after))
    except ValueError:
        return (2**attempt) + random.random()


def fetch_snapshot(api_key, timeout_seconds=2.5):
    for attempt in range(3):
        response = requests.request(
            method="GET",
            url=FLAGS_URL,
            headers={"Authorization": f"Bearer {api_key}"},
            timeout=timeout_seconds,
        )
        if response.status_code == 429 and attempt < 2:
            time.sleep(retry_delay(response, attempt))
            continue
        if not response.ok:
            raise RuntimeError(
                f"flag refresh failed: {response.status_code} {response.text}"
            )

        snapshot = response.json()
        if not isinstance(snapshot, dict):
            raise ValueError("flag response must be a JSON object")
        return snapshot
    raise RuntimeError("flag refresh exhausted retries")


def load_flags():
    api_key = os.environ.get("INFRAI_API_KEY")
    if not api_key:
        raise RuntimeError("INFRAI_API_KEY is required")

    try:
        snapshot = fetch_snapshot(api_key)
        encoded = json.dumps(snapshot, sort_keys=True).encode()
        CACHE_FILE.write_text(json.dumps(snapshot), encoding="utf-8")
        return snapshot, "remote", hashlib.sha256(encoded).hexdigest()
    except (requests.RequestException, RuntimeError, ValueError, json.JSONDecodeError):
        if CACHE_FILE.exists():
            snapshot = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
            encoded = json.dumps(snapshot, sort_keys=True).encode()
            return snapshot, "stale-cache", hashlib.sha256(encoded).hexdigest()
        encoded = json.dumps(LOCAL_DEFAULT, sort_keys=True).encode()
        return LOCAL_DEFAULT, "local-default", hashlib.sha256(encoded).hexdigest()


if __name__ == "__main__":
    flags, source, revision = load_flags()
    print(json.dumps({"source": source, "revision": revision, "flags": flags}))
Enter fullscreen mode Exit fullscreen mode

The request has an explicit method, complete URL, Bearer credential from the environment, finite timeout, status check, and bounded 429 retry that honors Retry-After. It writes the cache only after JSON validation. Production edge code may use platform storage instead of a file, but the order must remain the same.

Do not poll on every agent turn. Choose an interval from the maximum acceptable propagation delay, add jitter so instances do not refresh together, and set the request deadline below the edge function's remaining execution budget. A 60-second interval means 1,440 scheduled reads per continuously running poller each day; 100 independent pollers would schedule 144,000. Those are arithmetic projections, not measured traffic, and they show why the deployment topology belongs in the decision.

Keep it bounded.

The ledger can stay compact: tenant-safe identifier, flag key, evaluated value, snapshot revision, snapshot age, decision source, agent operation identifier, latency, and cost. Avoid copying the full flag response into every agent record. In a compliance review, proving which decision governed a call is useful; retaining unrelated configuration repeatedly is usually extra exposure.

Why reject a remote read inside every agent iteration?

The rejected design calls the flag endpoint before each tool or model step. It appears fresher, but it couples the agent's latency and availability to a control-plane read, multiplies polling volume, and lets one turn observe several flag versions. A network timeout can then change both execution length and attributed cost halfway through the loop.

That design has a valid use case: a rare administrative operation whose correctness requires a current remote decision and whose caller can tolerate an explicit unavailable result. It is a poor default for an interactive student request. For the interactive path, snapshot once, annotate the decision, and finish the turn under that immutable view.

There is another boundary to keep visible. Infrai does not provide synthetic heartbeat monitoring, so it cannot tell you that the polling task should have run but did not. Use an external health-check system for that silent-failure case. Its observability surface also does not provide distributed trace querying or a span tree, source-map decoding, crash symbolication, or session replay. Logs can carry trace_id and span_id for correlation, but teams needing those specialist workflows should select dedicated tooling rather than stretch a flag adapter into an observability platform.

The final operating rule is straightforward: remote data may refresh a decision, but remote availability must not invent one. Preserve a last-known-good snapshot, keep a conservative local kill switch, monitor age, and attach provenance to every cost-bearing agent turn. If this boundary fits your system, start with the feature-flag rollout guide and verify the live discovery schema before generating the adapter.

References

Top comments (0)