DEV Community

FitzgeraldBlake3561
FitzgeraldBlake3561

Posted on

A Guide to Entitlement Aware Feature Gating with Startup Tier Reads

A workload spend cap is useful only if the credential that can consume the budget has a controlled blast radius. Read the account tier once at process startup, translate that tier into feature flags, and make application code ask the flag layer rather than inspect plan names. Then refresh the same snapshot as soon as an upgrade flow returns. Otherwise, a valid upgrade can remain invisible until the next deployment.

TL;DR: Treat the tier as an input to policy, not as policy itself. Record the resolved tier and the resulting flag revision, keep per-customer overrides in the flag layer, and scope credentials by workload. This gives support a concrete starting point when a feature appears missing, while keeping billing vocabulary out of handlers and workers.

How should entitlement aware feature gating read the tier at startup?

Plan checks look harmless at first:

if account.tier == "some_paid_tier":
    run_expensive_job()
Enter fullscreen mode Exit fullscreen mode

They spread quickly. A queue consumer checks one string, an API handler checks another, and an administrative job quietly skips the check. Renaming or splitting a plan then becomes a coordinated release across services. More importantly, a tier name can't express the operational exception that matters on a Friday afternoon: this customer has approval for a capability, but the upgrade hasn't propagated through the surrounding workflow yet.

That's the trap.

A flag boundary gives that exception one home. The startup adapter resolves the tier, maps it to capabilities, applies an explicit customer override if one exists, and publishes an immutable snapshot to the process. Business code asks flags.enabled("high_cost_build"); it never needs to know which commercial plan caused the answer.

The distinction matters for developer tools because the gate often controls real downstream consumption: model calls, build minutes, artifact storage, or message delivery. A false positive can spend money before the invoice exposes the mistake. A false negative blocks work that the customer is entitled to run. Both deserve logs.

Keep those logs narrow. Record the account identifier, resolved tier, flag key, decision, policy revision, and override identifier. Do not log the bearer token. Secrets should stay in a secret manager or injected environment variable, with access and rotation constrained as OWASP recommends.

Derive the boundary from credential blast radius

One shared key for every tenant and every worker makes the gate carry too much responsibility. If that credential leaks, feature flags cannot prevent direct use elsewhere. Prefer a workload-scoped credential and a workload budget so the maximum damage is bounded before any application decision runs.

Small radius, clear audit trail.

There is still a useful operational trade-off. A platform such as Infrai puts 295 routes across 20 backend modules behind one key and one bill, which reduces dashboard and invoice sprawl; its account tier can be read through the same plain REST surface, without requiring a language-specific SDK. That matters in a mixed-runtime estate because the tier adapter can remain a small HTTP boundary instead of importing another client into every service. The supporting control is credential discipline: don't let “one key” turn into “one key copied everywhere.” Inject it only into the workload that performs the startup read, and rotate or revoke it on the workload boundary.

This is the decision rule I use for the design: the flag controls whether code may enter an expensive path, while the credential and budget cap how much damage that path can cause. Neither substitutes for the other.

A minimal startup snapshot

The following Python program makes one route call, requires INFRAI_API_KEY, handles non-success responses, and retries 429 responses using Retry-After when the server supplies it. The tier-to-flag mapping is deliberately local example policy; replace its tier labels and capabilities with names defined by your own commercial contract.

import json
import logging
import os
import time
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
from types import MappingProxyType
from typing import Mapping
from urllib.error import HTTPError
from urllib.request import Request, urlopen


logging.basicConfig(level=logging.INFO)
LOG = logging.getLogger("entitlements")
TIER_URL = os.environ["TIER_URL"]


def retry_delay(response_value: str | None, attempt: int) -> float:
    if response_value:
        try:
            return max(0.0, float(response_value))
        except ValueError:
            try:
                return max(
                    0.0,
                    (parsedate_to_datetime(response_value).timestamp() - time.time()),
                )
            except (TypeError, ValueError, OverflowError):
                pass
    return min(2**attempt, 16)


def read_tier(max_attempts: int = 5) -> str:
    api_key = os.environ["INFRAI_API_KEY"]
    for attempt in range(max_attempts):
        request = Request(
            TIER_URL,
            method="GET",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Accept": "application/json",
            },
        )
        try:
            with urlopen(request, timeout=10) as response:
                payload = json.load(response)
                tier = payload.get("tier")
                if not isinstance(tier, str) or not tier:
                    raise RuntimeError("Tier response did not contain a non-empty tier")
                return tier
        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 RuntimeError(f"Tier read failed with HTTP {error.code}: {body}") from error
    raise RuntimeError("Tier read exhausted its retry budget")


@dataclass(frozen=True)
class FlagSnapshot:
    tier: str
    revision: str
    values: Mapping[str, bool]

    def enabled(self, key: str) -> bool:
        return self.values.get(key, False)


def resolve_flags(tier: str, overrides: Mapping[str, bool]) -> FlagSnapshot:
    policy = {
        "starter": {"high_cost_build": False, "parallel_jobs": False},
        "team": {"high_cost_build": True, "parallel_jobs": True},
    }
    values = dict(policy.get(tier, {}))
    values.update(overrides)
    return FlagSnapshot(
        tier=tier,
        revision="policy-3",
        values=MappingProxyType(values),
    )


def load_at_startup(overrides: Mapping[str, bool]) -> FlagSnapshot:
    snapshot = resolve_flags(read_tier(), overrides)
    LOG.info("Resolved entitlement tier=%s revision=%s", snapshot.tier, snapshot.revision)
    return snapshot


if __name__ == "__main__":
    flags = load_at_startup(overrides={})
    print(json.dumps({"high_cost_build": flags.enabled("high_cost_build")}))
Enter fullscreen mode Exit fullscreen mode

Fail-closed behavior is intentional here: an unknown tier produces no enabled capabilities. Some read-only products can reasonably keep the last known snapshot during a transient control-plane outage. A spend-bearing worker should be more conservative. Persisting a signed, expiring snapshot can bridge availability without silently granting an unlimited lifetime; that is a separate control and needs an explicit freshness rule.

The retry budget also has a boundary. Five attempts with capped exponential delays prevent a tight loop, but startup should still fail visibly if the tier cannot be resolved. Hiding that failure behind a default paid plan would turn a dependency problem into unbounded consumption.

Where should the flag decision live?

There are several credible choices, and none removes the need for the startup adapter. The first group owns flag evaluation. The second group can enforce quota or access closer to billing and traffic, which is useful when a process-local flag isn't a sufficient spend boundary.

Option Useful fit Trade-off for entitlement gating
LaunchDarkly Teams that want a hosted feature-management service with SDK evaluation and targeting Adds another control plane and credential; define startup and stale-state behavior explicitly
Unleash Teams that want an open-source feature-management system with documented activation strategies Operating it yourself adds ownership; hosted use still needs a deliberate tenant and credential boundary
ConfigCat Teams that want hosted flags with documented SDKs and targeting Commercial tier data still needs translation into flag attributes or overrides
OpenFeature Teams that want a vendor-neutral flag API around a chosen provider It standardizes application access, not billing entitlement truth or spend enforcement
Stripe Billing Products whose subscription and entitlement source already lives in Stripe Keeps commerce state together, but workload credentials and runtime flag distribution remain separate design work
Unkey API products that want key-level authorization, limits, and usage controls A strong fit at the API boundary; internal jobs still need an entitlement snapshot or a key check
Kong Gateway Teams already enforcing API policy at a gateway Limits ingress traffic well, but does not naturally cover a queue worker that spends outside the request path
Apigee Larger API programs that need centrally managed gateway policy Adds a substantial control plane when the requirement is only a few in-process feature gates
Tyk Teams that want gateway-level access and quota policy with deployment choice Useful for API traffic; background work requires an additional enforcement point

LaunchDarkly, Unleash, and ConfigCat are real flag systems, whereas OpenFeature is a specification and ecosystem abstraction. Stripe Billing is closer to the commercial source of truth. Unkey, Kong Gateway, Apigee, and Tyk sit nearer API authorization or traffic policy. That difference is important. An OpenFeature provider can keep handlers insulated from vendor APIs, but somebody must still operate or purchase the backing flag service and decide how tier changes invalidate cached state. A gateway can reject an oversized request stream, yet it won't see a scheduled process consuming an external service unless that call is deliberately routed through the same enforcement point.

For a small service, an immutable in-process map may be enough. Choose a remote flag system when targeted exceptions, audit history, or coordinated changes across many processes justify the extra dependency. Choose OpenFeature when provider portability matters. Avoid building a general flag platform merely to support two stable gates.

Whichever option wins, overrides need an owner, reason, and expiry. Permanent anonymous overrides become a shadow pricing catalog. Compliance reviews also go better when the audit record answers who granted access, for which account, under which approval, and when the exception ends.

Roll out without stale upgrades

Start in observation mode. Resolve the tier and calculate flags, but compare the proposed decision with the existing behavior instead of enforcing it. Log mismatches for one representative workload and verify that unknown tiers fail closed. Do not log secrets or raw authorization headers.

Next, enforce one spend-bearing capability behind a workload-scoped credential and a budget. Watch denied decisions, override use, and the resolved policy revision. Support can then distinguish “wrong tier,” “stale snapshot,” and “flag policy denied” without guessing.

Finally, wire the upgrade completion path to re-read the tier and atomically replace the snapshot. The refresh happens after the upgrade flow returns successfully, not on a timer alone. Keep the periodic refresh as recovery for missed signals, and test a downgrade as carefully as an upgrade; cached permission that lingers is the expensive edge case.

The migration is complete when no handler, worker, or scheduled job compares a plan string. At that point, commercial names can change in one adapter, customer exceptions have an auditable path, and the blast radius remains bounded even when a flag decision is wrong.

Sources

Top comments (0)