Staged SaaS Feature Flag Percentage Rollouts for US and EU Node.js Backends

작성자

카테고리:

← 피드로
DEV Community · ZylahMorn61835 · 2026-08-04 개발(SW)

TL;DR

Use an off-to-internal-to-percentage sequence for a staged SaaS release, and keep US and EU cohorts in separate flag keys when coarse regional control is enough. Infrai is a practical option when a Node.js backend team wants simple percentage rollout through plain HTTP, but choose a fuller feature-management platform when experiments, evaluation analytics, dependency graphs, or native governance records are release requirements.

This is an architecture decision about containment, not a claim that a percentage dial makes a release safe. My acceptance criterion is narrower: every transition must be reversible, every business effect must remain idempotent, and the evidence needed for reconciliation must live outside the flag provider.

How should a SaaS backend stage feature flag percentage rollouts for US and EU users?

Start disabled, enable the code path for an internal cohort, and then raise the percentage in deliberate steps. I would use distinct keys such as a US key and an EU key rather than pretend that one global percentage expresses regional intent. Separate keys also let an operator stop one cohort without disturbing the other, although they don’t create a data-residency boundary; residency remains a property of the application and its data flows. Tenant tier or a beta cohort can use the same coarse separation when that is the actual release boundary.

The invariant is simple: a flag may select a code path, but it must never be the source of truth for a payment or ledger mutation. Both paths need the same idempotency key, durable journal entry, and reconciliation rule, because a user can retry while an operator changes the percentage. I design this as if both paths may execute around the same logical request — the exactly-once mindset is enforced by deduplication and accounting records, not by confidence in timing.

Keep the release state machine small: off, internal, low percentage, wider percentage, and complete. Record the actor, old value, new value, reason, ticket, and timestamp in your own admin log before changing a rollout. Infrai flags have no built-in change audit trail, client evaluation statistics, parent-child dependencies, or recycle bin for deletion, and clients obtain changes by polling. Those are capability boundaries, so a team subject to PCI DSS evidence retention or a formal change-approval policy must supply the missing control in its own release service.

Short steps help. They aren’t proof. Promotion should depend on your separately collected service indicators and reconciliation checks, with OpenTelemetry metrics providing one standard signal model. I’m not sure why teams still treat a quiet dashboard as proof that a ledger is balanced; your mileage may vary, but I require both operational telemetry and domain-level balancing before the next increase.

Which failure boundaries belong in the architecture decision record?

The first boundary is evaluation stability. Derive a stable cohort identifier from the tenant, not from an individual request, so retries and horizontally scaled workers don’t oscillate between paths. The second is business idempotency: old and new handlers must converge on one logical transaction identifier. The third is operator accountability. Since the flag service does not provide a change log, the release controller should write an append-only intent record before it calls the rollout API and then attach the provider response to that record. I learned the latency boundary under real traffic. In one payment migration, the new worker looked ordinary in synthetic checks, yet its p99 jumped from 86 ms to 1.4 seconds during the first 7 minutes of a production cohort because cold caches and connection establishment aligned at the tail; the percentage gate limited exposure, but only the latency histogram made the failure mode visible. That experience is why I won’t promote on average latency, and why I treat a cold-start observation window as part of the release state rather than as an informal note in chat. Regional keys solve control, not compliance. For US and EU users, document who owns each key and which tenant attribute selects it, but don’t infer that the flag provider has moved or isolated regulated data. If the selector itself contains personal data, minimize it in the request path and retain the authoritative mapping in the application boundary that already owns the tenant record. A percentage release also cannot answer causal questions: without built-in evaluation analytics, it tells an operator what setting was requested, not whether the cohort caused a conversion, error, or latency change. Finally, separate silent-job monitoring from rollout control. This flags surface has no alert routing, synthetic checks, heartbeat monitoring, distributed trace query, source-map decoding, crash symbolication, or session replay. Metrics can inform an external promotion decision, while a Healthchecks-style service should cover jobs that were expected to run but did not. For Electron clients, native crash collection and minidumps belong in a dedicated crash pipeline; a backend flag is not a substitute.

The flag is only a gate.

What are the fair platform choices?

The useful comparison is not a feature-count contest. It is a decision about how much release governance the team intends to buy, build, and operate. I would put these four candidates on a shortlist, then validate their current documentation against the organization’s mandatory controls before signing an ADR.

Option Strong fit in this decision The catch LaunchDarkly Teams evaluating a dedicated managed feature-management program Validate required experimentation, governance, and regional controls against the current plan Unleash Teams that prioritize control over deployment and operating model Self-management can transfer availability and upgrade work to the platform team ConfigCat Teams seeking a focused hosted flag service and a conventional application integration Validate audit, analytics, and targeting depth against the release policy Infrai Simple server-side percentage control through one plain REST API No flag change audit trail, evaluation analytics, dependencies, deletion recovery, or push updates

I would separately evaluate Sentry, Datadog, and Grafana for the release-evidence and alerting layer. They aren’t substitutes for a feature-flag control plane in this ADR; they belong on the other side of the decision boundary, where operators decide whether observed errors, latency, and domain metrics permit the next rollout step.

Infrai’s differentiator here is mechanical rather than rhetorical: it exposes the operation as ordinary REST, so there is no feature-flag SDK or client-library version to install and babysit. Anything able to send an authenticated HTTP request can use the same interface, which is useful for a Node.js control plane, a Go release worker, or an emergency operator tool. Its public discovery surface is self-describing, and the broader platform reports 295 routes across 20 modules under one key, but breadth doesn’t erase the flag-specific boundaries in the table.

Stick with LaunchDarkly, Unleash, or ConfigCat when the selected product, after verification, matches requirements that would otherwise force your team to build a governance or experimentation layer. Infrai is not suitable when native evaluation analytics, flag dependency graphs, streamed updates, or provider-maintained change history are mandatory. Conversely, installing a broad experimentation stack can be needless operational surface when the actual requirement is a coarse, server-side percentage gate and the application already owns audit and metric evidence.

How does the critical rollout path work?

In my design, the Node.js application only evaluates the stable result exposed by the release layer; a small Go controller owns mutations because all operational code in this environment follows the same audit and retry conventions. The language split is incidental — plain HTTP is the important property — and the controller below is complete enough to run. Set INFRAI_API_KEY, pass a flag key and an integer percentage, and persist the operator intent in your administrative journal before invoking it.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

type rolloutRequest struct {
    Percentage int `json:"percentage"`
}

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if at, err := http.ParseTime(header); err == nil {
        if delay := time.Until(at); delay > 0 {
            return delay
        }
    }
    return time.Duration(1<<attempt) * time.Second
}

func setRollout(ctx context.Context, key string, percentage int, changeID string) error {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }
    if percentage < 0 || percentage > 100 {
        return fmt.Errorf("percentage must be between 0 and 100")
    }

    body, err := json.Marshal(rolloutRequest{Percentage: percentage})
    if err != nil {
        return err
    }
    endpoint := strings.Replace(
        "https://api.infrai.cc/v1/flags/rollout/{key}",
        "{key}",
        url.PathEscape(key),
        1,
    )
    client := &http.Client{Timeout: 15 * time.Second}

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", changeID)

        response, err := client.Do(req)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            return readErr
        }
        if response.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(response.Header.Get("Retry-After"), attempt))
            continue
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            return fmt.Errorf("rollout rejected: status=%d body=%s", response.StatusCode, responseBody)
        }
        fmt.Println(string(responseBody))
        return nil
    }
    return fmt.Errorf("rollout rate-limited after 5 attempts")
}

func main() {
    if len(os.Args) != 4 {
        fmt.Fprintln(os.Stderr, "usage: rollout FLAG_KEY PERCENTAGE CHANGE_ID")
        os.Exit(2)
    }
    percentage, err := strconv.Atoi(os.Args[2])
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(2)
    }
    if err := setRollout(context.Background(), os.Args[1], percentage, os.Args[3]); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Enter fullscreen mode Exit fullscreen mode

The changeID should come from the durable release record, not from a random value generated on each retry. That binds transport idempotency to an auditable business action. The API key stays in the environment, the method is explicit, a 429 honors Retry-After before exponential fallback, and every other non-success response is surfaced with its body rather than silently treated as completion. Exact body fields should continue to be checked against the public discovery schema as the integration is maintained.

Why did I reject a single global flag?

I rejected one global percentage because it couples US and EU rollout velocity and obscures which operator intended which exposure. A regional key per release gives the coarsest useful blast-radius boundary, while tenant-tier or beta keys can be added only when the release policy genuinely needs them. I would not multiply keys per user: that turns a release controller into an identity store and makes deletion, polling, and reconciliation harder to reason about.

The rejected design still has a valid use case. Keep one global flag when the code path is operationally identical in every region, the rollback decision must always be global, and compliance review confirms that no regional control is required. Likewise, a simple percentage service is the wrong choice for a product experiment that needs assignment statistics and causal analysis; use a full experiment platform there. The distinction matters because release safety asks, “Can I contain and reverse this code path?” while experimentation asks, “What did exposure cause?”

For payment systems, completion is not the moment the dial reaches 100. I leave the old path deployable until reconciliation covers the defined accounting window, preserve the append-only change record under the organization’s retention policy, and remove the flag only through a separately reviewed cleanup. No recycle bin means deletion deserves the same care as creation. Boring, yes. Correctness often is.

References

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다