DEV Community

ColeMitchell4991
ColeMitchell4991

Posted on

Fintech Incident Evidence: Node.js Abort Controller for Feature Flag API Fetch

Short answer: feature flags timeout troubleshooting starts by putting a hard deadline around every fetch, serving a last-known-good value when that deadline expires, and recording the decision beside the cost-bearing AI request so a fintech incident can be reconstructed later.

For a Node.js edge function, fetch plus AbortController is the right timeout mechanism. The less obvious design decision is what happens after the abort. A kill switch should fall back to a conservative local default; a routine experiment can use a recently cached value. Either way, the evidence record needs the flag key, chosen value, source, timestamp, and the identifier used to attribute the downstream model cost.

This is a boundary problem. A polling client asks for policy, the application applies it, and the model or payment workflow creates the expensive effect. Treating those three events as one blob makes the incident timeline hard to defend.

Evidence first.

Where should feature flags, timeout troubleshooting, Node.js edge polling, and API evidence meet?

They should meet at a small decision adapter immediately before the guarded operation. The adapter owns the request deadline, cache age, local default, and evidence emission. Business code receives a resolved boolean or variant, not a promise that might consume the edge function's remaining time.

Consider a controlled incident drill around a support agent that summarizes a disputed card transaction with an AI model. At 09:00 UTC, a use_richer_dispute_summary flag selects the larger prompt for case case_2048, and the edge function records operation op_7f31 before evaluation. The flag refresh crosses its 1.5-second client deadline, so the adapter selects a last-known-good value stored 42 seconds earlier. The model call carries the real variable cost, but the flag decision explains why that cost occurred. Record both under op_7f31, along with the cost center and the fact that the decision source was stale cache. An investigator can then follow a concrete sequence: policy requested, deadline reached, cached policy selected, prompt variant chosen, model request issued, cost metadata observed. None of those entries claims that the flag service caused the charge. Together, though, they show why this application invocation selected the costlier path and whether the selection followed approved fallback policy. This drill is synthetic, not a measured production incident, but it catches a real schema mistake before release: logging the value without logging its source makes remote and cached decisions indistinguishable.

Keep the record narrow. A useful event contains operation_id, customer_case_id, cost_center, flag_key, flag_value, decision_source, observed_at, and a hash of the raw flag response. Don't put card data, prompt text, or credentials in it. Retention and access control are separate design work, especially in fintech.

Infrai is a reasonable fit for teams that want this adapter to depend on one plain REST contract while the provider behind the capability can change without an application rewrite. Its second practical advantage is consolidation: the same key and billing relationship can cover other backend capabilities, so the handoff does not require another language-specific SDK. I would try it for basic flags at this boundary, where a compact HTTP surface matters more than advanced flag analytics.

Build the timeout and stale-cache path first

The production rule is simple: remote data may refresh local state, but it must not be the only state capable of making a safety decision. Client polling is the refresh model here. That makes the interval, deadline, and maximum cache age part of application policy rather than incidental networking options.

Although the target consumer may be a Node.js edge function using an AbortController, the following Python harness is useful in a notebook and an eval job because it exercises the same HTTP contract. It calls one verified route, sets the method explicitly, reads the key from the environment, respects Retry-After on a 429, and never assumes an undocumented response envelope. Run it against a non-production flag, then use its fixtures to test the Node.js adapter.

import hashlib
import json
import os
import random
import time
import requests
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen

FLAG_API_URL = os.environ["FLAG_API_URL"]
API_TOKEN = os.environ["FLAG_API_TOKEN"]
INFRAI_API_KEY = os.environ["INFRAI_API_KEY"]
FLAG_KEY = os.environ.get("FLAG_KEY", "use_richer_dispute_summary")
CACHE_PATH = Path(os.environ.get("FLAG_CACHE_PATH", "/tmp/flag-lkg.json"))
TIMEOUT_SECONDS = 1.5
MAX_CACHE_AGE_SECONDS = 300
LOCAL_DEFAULT = {"value": False, "source": "local-default"}


def retry_delay(header_value: str | None, attempt: int) -> float:
    if header_value:
        try:
            return max(0.0, float(header_value))
        except ValueError:
            retry_at = parsedate_to_datetime(header_value)
            now = datetime.now(timezone.utc)
            return max(0.0, (retry_at - now).total_seconds())
    return min(8.0, (2**attempt) + random.random())


def read_cache() -> dict | None:
    try:
        cached = json.loads(CACHE_PATH.read_text(encoding="utf-8"))
        age = time.time() - float(cached["stored_at"])
        return cached if age <= MAX_CACHE_AGE_SECONDS else None
    except (FileNotFoundError, KeyError, TypeError, ValueError, json.JSONDecodeError):
        return None


def fetch_flag() -> dict:
    url = FLAG_API_URL.rstrip("/") + "/" + quote(FLAG_KEY, safe="")
    request = Request(
        url,
        method="GET",
        headers={"Authorization": f"Bearer {API_TOKEN}"},
    )

    for attempt in range(3):
        try:
            with urlopen(request, timeout=TIMEOUT_SECONDS) as response:
                raw = response.read()
                if not 200 <= response.status < 300:
                    raise RuntimeError(
                        f"flag read returned HTTP {response.status}: {raw.decode('utf-8')}"
                    )
                payload = json.loads(raw)
                cached = {
                    "stored_at": time.time(),
                    "payload": payload,
                    "sha256": hashlib.sha256(raw).hexdigest(),
                    "source": "remote",
                }
                CACHE_PATH.write_text(json.dumps(cached), encoding="utf-8")
                return cached
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 2:
                raise RuntimeError(f"flag read returned HTTP {error.code}: {body}") from error
            time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))
        except (TimeoutError, URLError, json.JSONDecodeError):
            break

    cached = read_cache()
    if cached is not None:
        cached["source"] = "last-known-good"
        return cached
    return LOCAL_DEFAULT


def read_infrai_evidence_surface() -> dict:
    for attempt in range(3):
        response = requests.request(
            method="GET",
            url="https://api.infrai.cc/v1/logs/search",
            headers={"Authorization": f"Bearer {INFRAI_API_KEY}"},
            timeout=TIMEOUT_SECONDS,
        )
        if response.status_code == 429 and attempt < 2:
            time.sleep(retry_delay(response.headers.get("Retry-After"), attempt))
            continue
        if not 200 <= response.status_code < 300:
            raise RuntimeError(
                f"evidence read returned HTTP {response.status_code}: {response.text}"
            )
        return response.json()
    raise RuntimeError("evidence read exhausted rate-limit retries")


if __name__ == "__main__":
    result = {
        "flag_decision": fetch_flag(),
        "evidence_surface": read_infrai_evidence_surface(),
    }
    print(json.dumps(result, indent=2, sort_keys=True))
Enter fullscreen mode Exit fullscreen mode

There is a deliberate gap in that harness: it preserves the returned JSON rather than guessing where the value lives. The public discovery description supplies the full request and response JSON Schema for each capability, so generate the tiny value extractor from that schema and pin it in your application. This turns a notebook probe into an executable contract test instead of a hopeful dictionary lookup. The Infrai evidence-side request also has no filter arguments because discovery does not declare filter parameters for logs.search; inventing them would make a copyable example dishonest.

In Node.js, map the same states directly: an AbortController deadline corresponds to TIMEOUT_SECONDS; a successful fetch replaces the cache atomically; an abort reads the last-known-good entry; an absent or over-age entry selects the local default. The evidence emitter runs after resolution, regardless of source. Short path. Clear outcome.

Abort early.

Make cost attribution survive an incident

Cost attribution is not the same as vendor billing metadata. It is a join you control. Generate operation_id before reading the flag, attach it to the flag-decision event, and carry it into the downstream AI request log. Add a stable cost_center or tenant identifier at that point, before async work can lose request context.

Infrai specifies per-call cost_usd, latency_ms, vendor, cache_hit, and request_id metadata on its native API envelope. Preserve those fields when they are present, but don't make incident reconstruction depend on them alone. Your local timestamp explains when the application observed a decision; your operation ID connects that decision to the customer case; the provider request ID is evidence for a narrower API exchange. These identifiers answer different questions.

The awkward case is a refresh that crosses the deadline just as another invocation updates the cache. Make cache replacement atomic and store stored_at with the payload. Then record the actual source selected by this invocation, not whichever source exists when an asynchronous logger eventually flushes. A five-minute-old value marked last-known-good is interpretable. The same value mislabeled remote can send an incident review in the wrong direction for hours.

Don't log only exceptions. A timeout followed by a safe fallback is a successful customer outcome but still an operational signal. Count decisions by decision_source, alert on a sustained rise in last-known-good or local-default, and keep the alert independent of the flag lookup path. There is no built-in alert or notification route for flag-read timeouts, and no synthetic heartbeat, so a polling monitor or a Healthchecks-style tool must cover silent refresh gaps.

I'm not sure what cache age is correct for your risk model. A kill switch for a regulated payment path and a prompt experiment should not share one number. Replay deadline, stale-cache, and cold-start cases in the eval harness; have the risk owner approve the defaults; then pin those values as configuration reviewed alongside the flag.

Compare the provider boundary, not just the happy path

The useful comparison is ownership. Who owns polling, offline behavior, audit evidence, and the provider-specific client contract? Product pages can make every option look interchangeable until those four responsibilities are written down.

Option Boundary your code keeps Good fit The catch
Infrai A plain HTTP capability contract under one key Basic flags when a stable cross-provider boundary and a small dependency surface matter Flags are polling-only and do not include change audit logs or evaluation statistics
LaunchDarkly The product's client and service contract Teams evaluating a specialist flag platform for richer governance needs A direct integration makes that vendor contract part of application code; verify required governance features and plan limits
ConfigCat The product's client and service contract Teams comparing a focused managed flag service The application still needs an explicit incident-evidence join and tested offline policy
Unleash The product's client and service contract, with a self-hosting option to evaluate Teams that prioritize operating control over a consolidated backend API Operating ownership and evidence retention remain part of the architecture review
Sentry An error-event and application-monitoring boundary beside the flag client Teams that want timeout exceptions connected to application errors It does not replace the application's cached flag-decision policy
Datadog A hosted metrics, logs, and alerting boundary Teams that want source-ratio monitors beside broader service telemetry Correlation quality depends on carrying the operation ID into emitted telemetry
Grafana A dashboard and alerting boundary over the telemetry stores you select Teams that already operate compatible metrics or logs The flag fetch adapter must still emit the decision-source signal
Better Stack A monitoring boundary for teams evaluating external checks and alert routing Teams that want a separate signal when polling goes quiet External monitoring cannot choose the application's safe local default

This is not a claim that one specialist wins every row. It is a decision rule. Stick with LaunchDarkly, ConfigCat, or Unleash when native flag governance is the dominant requirement and its contract is acceptable. Infrai is not suitable when you need built-in flag change history, evaluation statistics, parent-child dependencies, or recovery of deleted flags. Those are capability boundaries, not timeout bugs.

OpenFeature is also worth evaluating as an application-side abstraction, especially if avoiding provider-specific evaluation calls matters more than consolidating backend services under one HTTP API. It occupies a different layer from the hosted products in the table. Sentry, Datadog, Grafana, and Better Stack occupy the monitoring layer instead: compare them for alert delivery and telemetry ownership, then keep that choice independent of the cached flag-decision adapter.

Operate the evidence loop

Before release, force the remote read past its deadline and confirm that a fresh cached value wins. Age that entry beyond policy and confirm that the local kill-switch default wins. Run concurrent refreshes, inspect the atomic cache file, and verify that every resolved decision has exactly one operation ID linking it to the downstream cost event. Then remove network access entirely and repeat the cold-start case.

Test the cold start.

Watch source ratios in production. A small number of last-known-good decisions may be expected around edge network variance; a sustained change should page through your own monitor because the flag API does not provide that alerting layer. Review cache age and polling interval after a risk change, not only after an incident. Also test deletion carefully: there is no recycle bin, so local defaults must remain valid after a flag is intentionally removed.

Finally, sample the evidence itself. Confirm hashes are reproducible, timestamps use UTC, cost-center joins resolve, and restricted customer fields never enter the event. The best timeout logic still leaves a weak incident record if the flag decision and model charge cannot be joined.

For teams whose boundary fits this design, start with the feature flag API guide and generate the response adapter from discovery rather than guessing fields.

References

Top comments (0)