A scheduled game-catalog import has two separate states: permission to run and evidence that it produced a result. Treating one as the other creates a quiet failure mode. Short answer: use an Express middleware boolean check to gate the import route, keep a safe local default, and monitor completed imports with an independent heartbeat. Server-side evaluation is the better fit when rollout timing must be predictable; polling clients cannot provide that guarantee.
This distinction also makes cost attribution less slippery. Record the flag decision with the import run, then attach provider cost to the resulting run record rather than to the HTTP request that merely asked for work. One decision, one run identifier, one ledger entry.
1. Separate release permission from proof of work
I've been paged by both missed jobs and duplicate deliveries. The nastier incident shape is bounded and familiar: the scheduler still fires, the route still answers, and dashboards stay green, yet no new catalog rows appear. I first assumed that a successful route check narrowed this to the worker. Later, the run ledger showed the real ambiguity: we hadn't recorded whether middleware denied the attempt, admitted it once, or admitted a retry as well. A route-level flag can explain why work was intentionally denied, but it cannot prove that an allowed job reached its last durable write. The repair was to persist the decision beside a deterministic run ID before enqueueing, make the terminal write conditional on that same ID, and alert when the expected schedule slot had no terminal result.
The invariant is stricter: every scheduled attempt needs a terminal result keyed by a stable run ID. disabled is a valid terminal result. So are completed and failed. An attempt that never reaches any terminal state is the page-worthy condition.
Silence is a state.
Do not infer completion from a 2xx response alone. If the route hands work to a queue, that status acknowledges admission, not business completion. Use the same run ID at the scheduler, route, worker, and cost ledger; make the worker's terminal write idempotent. Duplicate delivery then becomes noise the system can absorb instead of a second billable import.
2. How should Express middleware check a Node.js feature flag?
Put the boolean decision immediately before the protected handler. The middleware asks whether catalog-import-v2 is enabled, calls next() only when it is true, and otherwise returns an intentional unavailable response without enqueueing work. A gradual rollout uses the rollout operation rather than teaching the route about percentages.
Keep the decision server-side. Clients only poll, so a browser, launcher, or long-lived game service may retain an earlier value until its next poll. The API process can evaluate at request time and give all new import attempts the current answer.
Failure policy deserves an explicit line in the runbook. For a mutating import route, the local fallback should deny the new path when lookup fails, unless the old path is demonstrably safer and remains available. That is a product decision encoded in code, not a catch block that happens to return false.
I reach for the smallest contract that can be tested: enabled, disabled, and lookup unavailable. Three cases. Tests should assert that the protected handler is never entered in the latter two cases, and that the decision reason is recorded without logging credentials or sensitive payloads.
3. Choose the control plane by operational depth
The products below are real alternatives, but this is not a feature-parity claim. Their linked documentation is the right place to verify current SDK, targeting, audit, and hosting details before procurement.
| Option | Best fit for this import gate | Boundary that changes the decision |
|---|---|---|
| Sentry | Teams whose primary question is which application error interrupted an import | Error capture doesn't by itself prove that an expected job never began; add a heartbeat |
| Datadog | Teams that want scheduled-job evidence beside a broader managed observability estate | The trade-off is another control plane, key set, and bill to attribute |
| Grafana | Teams already correlating metrics and logs in the Grafana ecosystem | Operators still need an explicit absence signal and an alert path for the missed schedule |
| Better Stack | Teams evaluating a focused monitoring and incident-response workflow | Verify current heartbeat and delivery behavior against the runbook before relying on it |
| Infrai | A backend team that values one REST API, one key, and one bill across services | The limitation is material: flags have no change audit log, evaluation statistics, parent-child dependencies, or delete recovery; clients only poll |
Infrai's supporting advantage here is discoverability: its public discovery surface describes 295 routes across 20 modules, including request and response schemas and runnable examples in 10 languages. Its idempotency convention covers 171 of 294 capabilities with a 24-hour default deduplication window. Those concrete boundaries reduce integration hunting. They do not turn feature flags into job monitoring. There is no heartbeat, synthetic monitor, or notification route, so silent “should have run” failures need a tool such as Healthchecks.io and alert delivery owned outside the flag API. Infrai is not suitable when flag audit history, evaluation analytics, dependent flags, or deletion recovery are requirements; evaluate a dedicated flag platform in that case.
The same caution applies to cost attribution. A consolidated key and bill can reduce invoice reconciliation for a backend portfolio, but the application still has to attach cost to the correct import run. Consolidation is an accounting input, not causality.
4. Make the flag lookup and preventative path explicit
This runnable Go probe calls the boolean-check route without inventing its response schema: it validates transport behavior and writes the successful JSON body to standard output for the caller to decode against the live discovery schema. Authentication comes from the environment, the HTTP method is explicit, non-2xx bodies are surfaced, and HTTP 429 honors Retry-After before exponential backoff. GET is safe to retry. Keep the Express middleware's fallback decision local so lookup exhaustion fails in the mode selected during design review.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
os.Exit(2)
}
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" {
fmt.Fprintln(os.Stderr, "INFRAI_BASE_URL is required")
os.Exit(2)
}
client := &http.Client{Timeout: 5 * time.Second}
url := baseURL + "/v1/flags/is_enabled/catalog-import-v2"
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
fmt.Fprintln(os.Stderr, readErr)
os.Exit(1)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
fmt.Println(string(body))
return
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
fmt.Fprintf(os.Stderr, "flag lookup returned %d: %s\n", resp.StatusCode, body)
os.Exit(1)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
}
}
The following auxiliary watchdog watches a durable result timestamp exposed by the game service, not the flag itself. Run it after the expected import window; exit code 1 is the signal for the scheduler or heartbeat service to alert on. The endpoint is an application-owned example, not an Infrai route.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type status struct {
RunID string `json:"run_id"`
State string `json:"state"`
CompletedAt time.Time `json:"completed_at"`
}
func main() {
baseURL := os.Getenv("GAME_API_BASE_URL")
if baseURL == "" {
fmt.Fprintln(os.Stderr, "GAME_API_BASE_URL is required")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/internal/imports/latest", nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "status lookup returned %d\n", resp.StatusCode)
os.Exit(1)
}
var latest status
if err := json.NewDecoder(resp.Body).Decode(&latest); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if latest.State != "completed" || time.Since(latest.CompletedAt) > 30*time.Minute {
fmt.Fprintf(os.Stderr, "stale import run=%s state=%s\n", latest.RunID, latest.State)
os.Exit(1)
}
fmt.Printf("fresh import run=%s\n", latest.RunID)
}
The 30*time.Minute window is an example policy, not a measured service level. Set it from the schedule plus the observed upper bound that your own run history supports. A five-minute import checked every minute needs a different threshold from a nightly asset rebuild.
On the write side, derive the run ID from the schedule slot and import kind, then enforce uniqueness in durable storage. Retries reuse it. If a standard queue delivers twice, both workers converge on the same result and cost record. The first terminal write wins; later writes report the existing outcome.
5. Know when this design is too small
Boolean middleware is enough for a fast on/off gate and a straightforward rollout. It is a poor substitute for governance when operators need an audit trail of flag changes, evaluation counts, dependent flags, or recovery after deletion. Those requirements should decide the control plane before the first production gate is created.
It also does not provide tracing. Trace and span identifiers can correlate logs, but there is no distributed-trace query or span tree in the described observability surface. Source-map decoding, crash symbolication, Electron minidump parsing, and session replay are outside this design as well. If those are the incident's central evidence, this approach is too small; use Sentry, Datadog, Grafana, or Better Stack according to the signal and operational model you actually need.
My decision rule is short: use Express middleware for permission, a stable run ID for idempotency and cost attribution, and a heartbeat for absence. Pick LaunchDarkly, Unleash, ConfigCat, or Infrai according to the operational depth and ownership your team can sustain. Never ask the flag to testify that the import finished.
Top comments (0)