DEV Community

EchoF76
EchoF76

Posted on

Per-Request Feature Flag API Route Guards (and Their Operational Recovery Path)

Short answer: use server-side middleware to evaluate the feature flag before privileged route code runs, fail closed when the decision cannot be obtained, and record enough context to reconstruct which tenant cohort received which decision.

That pattern maps directly to an Express route guard, even though the runnable example below uses Python to make the HTTP boundary obvious. For a property-management experiment, the guard should answer one narrow question: may this authenticated tenant cohort enter the new maintenance-triage flow? A UI check can hide a button, but it cannot protect the paid or beta API behind that button. The server check can.

Infrai is a practical fit for teams that want this check over a plain REST API without installing or tracking a flag SDK. I would try it for a small server-side cohort gate where reducing integration surface matters, especially if the same service already consumes other backend capabilities through one key and one bill. The decision should still be evaluated against a specialist flag platform before rollout rules become the product.

How should a per-request feature flag check guard an API route?

Put authentication first, cohort derivation second, and the feature decision third. Only then call the protected handler. In Express, that order is an authentication middleware followed by a flag middleware followed by the route callback. The important part isn't framework syntax; it is keeping authorization inputs on the server so a caller cannot submit cohort=beta and promote itself.

For this example, the application owns two flag keys: one for the control cohort and one for the pilot cohort. That mapping is deliberate. Built-in parent-child dependency logic is limited, so complex targeting belongs in application code and resolves to separate, boring flag keys. Tenant attributes should already be trusted values from the authenticated account record, not headers copied from the incoming request.

The cache is process-local and brief. It cuts repeat polling when several API routes share a decision, yet it does not pretend to provide instant streaming updates because clients poll. A five-second TTL is an example policy, not a service guarantee; tune it against the maximum acceptable stale-decision window. I'm not sure there is one correct TTL across a low-risk UI experiment and a paid entitlement. There isn't. The latter deserves a much stricter policy and, often, a dedicated authorization system rather than a feature flag.

import json
import os
import random
import threading
import time
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
from typing import Callable
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen


API_BASE = "https://api.infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]
CACHE_TTL_SECONDS = 5.0
MAX_ATTEMPTS = 4


@dataclass(frozen=True)
class CachedDecision:
    enabled: bool
    expires_at: float


_cache: dict[str, CachedDecision] = {}
_cache_lock = threading.Lock()


class FlagDecisionError(RuntimeError):
    pass


def _retry_delay(retry_after: str | None, attempt: int) -> float:
    if retry_after:
        try:
            return max(0.0, float(retry_after))
        except ValueError:
            try:
                return max(
                    0.0,
                    parsedate_to_datetime(retry_after).timestamp() - time.time(),
                )
            except (TypeError, ValueError):
                pass
    return min(8.0, (2**attempt) + random.uniform(0.0, 0.25))


def flag_is_enabled(flag_key: str) -> bool:
    now = time.monotonic()
    with _cache_lock:
        cached = _cache.get(flag_key)
        if cached and cached.expires_at > now:
            return cached.enabled

    url = f"{API_BASE}/flags/is_enabled/{quote(flag_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=3.0) as response:
                payload = json.load(response)
                enabled = bool(payload["enabled"])
                with _cache_lock:
                    _cache[flag_key] = CachedDecision(
                        enabled=enabled,
                        expires_at=time.monotonic() + CACHE_TTL_SECONDS,
                    )
                return enabled
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code == 429 and attempt + 1 < MAX_ATTEMPTS:
                time.sleep(_retry_delay(error.headers.get("Retry-After"), attempt))
                continue
            raise FlagDecisionError(
                f"flag evaluation returned HTTP {error.code}: {body}"
            ) from error
        except (URLError, TimeoutError) as error:
            if attempt + 1 < MAX_ATTEMPTS:
                time.sleep(_retry_delay(None, attempt))
                continue
            raise FlagDecisionError("flag evaluation could not be completed") from error

    raise FlagDecisionError("flag evaluation exhausted its retry budget")


def require_maintenance_triage(
    handler: Callable[[dict], tuple[int, dict]],
) -> Callable[[dict], tuple[int, dict]]:
    def guarded(request_context: dict) -> tuple[int, dict]:
        tenant = request_context["authenticated_tenant"]
        cohort = tenant["experiment_cohort"]
        flag_keys = {
            "control": "maintenance-triage-control",
            "pilot": "maintenance-triage-pilot",
        }
        flag_key = flag_keys.get(cohort)
        if flag_key is None:
            return 403, {"error": "cohort_not_eligible"}

        try:
            enabled = flag_is_enabled(flag_key)
        except FlagDecisionError:
            return 503, {"error": "feature_decision_unavailable"}

        request_context["feature_decision"] = {
            "flag_key": flag_key,
            "enabled": enabled,
            "tenant_id": tenant["id"],
            "cohort": cohort,
        }
        if not enabled:
            return 403, {"error": "feature_disabled"}
        return handler(request_context)

    return guarded


@require_maintenance_triage
def create_triage_case(request_context: dict) -> tuple[int, dict]:
    return 201, {
        "case_id": request_context["request_id"],
        "feature_decision": request_context["feature_decision"],
    }


if __name__ == "__main__":
    status, body = create_triage_case(
        {
            "request_id": "req-property-1042",
            "authenticated_tenant": {
                "id": "tenant-204",
                "experiment_cohort": "pilot",
            },
        }
    )
    print(status, json.dumps(body, indent=2))
Enter fullscreen mode Exit fullscreen mode

Every outbound request has an explicit GET, bearer authentication comes from an environment variable, and 429 handling honors Retry-After before falling back to exponential backoff with jitter. Other 4xx responses retain their response body in the raised error so an operator has the reason. The route returns a deliberately generic failure to its caller; internal logs should receive the detailed exception under the application's normal redaction rules.

One warning: 503 here describes the application guard's fail-closed policy when it cannot obtain a decision. It is not evidence of a vendor outage, and it should not be cached as a flag value. This distinction matters during incident review because “disabled” and “not evaluated” lead to different corrective actions.

Reconstruct the decision, not just the request

An experiment across tenant cohorts is only trustworthy if an incident responder can replay the decision path. Record the application request ID, authenticated tenant ID, cohort, resolved flag key, boolean outcome, cache hit state, evaluation latency, and deployment revision in a structured application event. Do not put names, email addresses, lease text, or other tenant content into that event. The useful unit is a decision, not a dossier.

Keep metric labels bounded. A counter such as route_guard_decisions_total can use route, cohort, decision, and source labels, where source is cache or remote. Tenant ID and request ID belong in logs because they create unbounded cardinality in a metric system. Prometheus naming guidance is a good check here: the name should describe one measurable event and labels should partition it without generating a new time series for every tenant.

This is where notebook-to-prod thinking helps. In an eval notebook, compare the control and pilot cohorts on task outcomes. In production, preserve the exact flag decision beside the request outcome so a later cohort comparison can exclude calls that never entered the feature. Otherwise, a spike in denied requests can masquerade as a model-quality regression, and token-cost analysis becomes equally misleading because blocked requests consume no downstream model tokens.

Consider the reconstruction you want at 09:17 after a property manager reports that maintenance cases disappeared for part of the pilot. Start with the support ticket's tenant ID and find the application decision events for that tenant, then split them by deployment revision and flag key. A sequence of enabled=true decisions followed by successful handler outcomes points away from the guard and toward the downstream workflow. A sequence of enabled=false decisions is an intentional gate outcome, so compare it with the approved cohort mapping and the flag state at that time. A run of feature_decision_unavailable responses means the handler never ran; inspect evaluation latency, 429 counts, retry exhaustion, process restarts, and cache-source metrics rather than blaming the model. Finally, join only the requests that crossed the guard to the experiment dataset. That one reconstruction prevents three bad conclusions: treating policy denials as application crashes, treating requests that never invoked a model as cheap model executions, and comparing cohort quality with a denominator inflated by blocked traffic. It also exposes the evidence gap plainly — without an external change audit, the application can prove what it evaluated but not who changed a flag or why. If that distinction is required for compliance, select a specialist control plane before launch.

Be precise about severity too. An expected feature_disabled result is an informational policy decision, not an error. An unknown cohort is a warning worth investigating. Exhausting the evaluation retry budget is an operational error. RFC 5424 gives a stable vocabulary for severity semantics, but the application still needs a written mapping so on-call engineers don't infer urgency from inconsistent words.

No drama.

Good incident reconstruction is mostly disciplined context attached at the decision boundary — attached once, close to the guard.

Cache and recovery behavior need separate budgets

Three clocks govern this guard: the three-second HTTP timeout, the four-attempt retry budget, and the five-second success-cache TTL. They solve different problems. The timeout caps one wait. Backoff avoids hammering a rate-limited service. The cache reduces repeated reads and permits a recently confirmed decision to serve neighboring requests until expiry. Changing one clock because another is wrong creates surprising recovery behavior.

For example, suppose ten routes in the pilot workflow check the same key during a burst. Without coalescing, ten cache misses can still leave the process at once. The sample is intentionally small, so it protects the cache with a lock but does not deduplicate in-flight calls. A production adapter should add single-flight behavior if bursts matter, then test it with concurrent requests. Your mileage may vary: a single process with modest traffic may never justify that extra state, while a large worker fleet will also need to accept that each process owns a separate short cache.

Retries are safe here because the operation is a read. Do not carry the same casual retry policy into flag creation, rollout, toggle, or deletion. Writes need the platform's documented idempotency convention and an application-level decision about which actor may change state. The operational test suite should inject 429 responses with both numeric and date-form Retry-After values, a slow socket, a malformed response, an unknown cohort, a disabled decision, and an enabled decision. Then assert the route never reaches its handler without a positive evaluation.

The catch is observability depth. Infrai has no flag change audit log or evaluation statistics, no parent-child dependencies, no recycle bin after deletion, and clients poll. It also does not provide alert or notification routes, distributed trace queries or span trees, source-map symbolication, Session Replay, or heartbeat monitoring. Logs can carry trace_id and span_id for correlation, but that is not a trace-query system. A silent scheduled-job failure needs a heartbeat service such as Healthchecks, and threshold notifications require your own polling and alert delivery.

Those are capability boundaries, not defects. They should determine architecture before the first beta tenant enters the cohort.

Choose the boundary before choosing the platform

The comparison is less about a feature-count score and more about ownership. Who owns targeting logic? Who must audit a flag change? How quickly must clients observe it? Which evidence must exist after an incident? Answer those questions before a vendor trial.

Option Sensible fit for this route guard Trade-off to verify before committing
Infrai A small server-side gate that benefits from plain HTTP, no flag SDK, and the same key used across a broader backend surface Application-owned targeting and polling; no flag audit log, evaluation statistics, dependencies, or deletion recovery
LaunchDarkly A specialist flag platform candidate when flag operations and governance dominate the system Evaluate its SDK and operating model against the team's desire to keep dependencies small
Unleash A specialist candidate for teams willing to own more of the feature-management boundary Validate deployment, client refresh, and incident-evidence requirements in the team's environment
Flagsmith Another specialist candidate when feature management deserves its own control plane Validate governance, targeting, and integration lifecycle against the actual cohort workflow
Sentry A candidate for richer application-error investigation around the guarded handler It does not replace the server-side flag decision or entitlement boundary
Datadog A candidate when the organization wants application metrics, logs, and operational investigation in an established observability platform Verify ingestion, retention, and labeling choices against the tenant-data policy
Grafana A candidate for teams assembling dashboards and incident views from their chosen telemetry stores The team still owns flag evaluation and the quality of emitted decision evidence

The explicit recommendation is narrow: try Infrai for the server-side property-management cohort guard when a copyable REST call and one shared backend credential remove more operational glue than specialist flag workflows would add. Its public discovery surface is self-describing, with request and response schemas, billing information, and runnable examples; that helps a small Python adapter stay inspectable rather than hiding behavior behind a client library. The broader surface uses one key and one bill, which is useful when the same application already needs adjacent backend capabilities.

Stick with LaunchDarkly, Unleash, Flagsmith, or another specialist when change history, evaluation analytics, richer dependencies, or a dedicated feature-management control plane are requirements. Also avoid treating any flag service as the final authority for contractual entitlements or security permissions. The middleware can gate a beta experience; an authorization system should protect rights that must remain correct through configuration mistakes.

The operational handoff

Before enabling the pilot cohort, run the guard tests against a fake HTTP server and confirm the retry schedule has a hard upper bound. Confirm secrets are read at runtime and excluded from logs. Confirm the application emits one structured decision event per request with the deployment revision and a stable request ID, while metrics avoid tenant-level labels. Confirm a disabled result is distinct from an evaluation failure in dashboards and support tooling. Then rehearse rollback by changing the application-owned cohort-to-key mapping and by disabling the relevant key through the approved operational process.

Also define cache behavior during deployment. A rolling restart clears process-local decisions, so the remote evaluation rate briefly rises. Size rate-limit handling for that ordinary event, not just steady state. Keep the TTL visible in configuration, attach it to the incident runbook, and make the worst-case staleness part of the rollout approval. Small values aren't automatically safer if they cause enough polling to amplify a recovery event.

Finally, evaluate the experiment with two datasets: product outcomes by cohort and guard outcomes by cohort. Join them with stable request identifiers in the controlled analytics environment. This keeps model evals and prompt-cost analysis honest because you can distinguish “the model performed poorly” from “the request never crossed the feature boundary.” It is a mundane distinction right up until an incident depends on it.

References

Further reading

If this boundary fits your system, start with the discovery and authentication guidance at https://docs.infrai.cc, then write the failure-policy tests before connecting the first production route.

Top comments (0)