DEV Community

KillianBerg5391
KillianBerg5391

Posted on

Implementing Node.js Notification Rollbacks: Auto-Disable Feature Flags from Polled Errors

Short answer: put each risky notification path behind an operational feature flag, poll recent error groups in a separate worker, and auto-disable the flag only after a sustained failure budget is exhausted; let a person decide when to re-enable it.

For an edtech notification service, that last clause is the rollback boundary. Automatic shutdown limits another round of failed class reminders, while manual recovery prevents a briefly healthy poll from releasing a queued wave of mail. The Node.js application only needs to check the flag before delivery. Detection, durable state, and team notification belong in the worker.

This is a brake, not an autopilot.

Keep it boring.

What should a Node.js feature flag kill switch do after repeated poll errors?

It should make one conservative transition: enabled -> disabled. Start with a feature flag for each provider or risky delivery path, then have a singleton worker poll recent error groups. A qualifying sample increments a consecutive-failure counter; a clean sample resets it. When the counter reaches the policy limit, the worker toggles the flag once, persists that decision, and emits an event to the team's existing Slack or email sender.

Why require repeated samples? One burst might be a bad lesson import, an expired recipient address batch, or a provider-wide problem. Those cases have different owners even when their raw failure totals look similar. A policy such as 25 failures across three consecutive 30-second polls is a test input, not a universal recommendation. I don't know your traffic shape, and your mileage may vary. Replay labeled notification sequences before promoting any threshold from a notebook into production.

The rollback budget should also reflect the message class. Password-reset mail has a much smaller delay tolerance than a weekly progress digest, so one threshold across both is hard to defend. Separate flags make the decision legible: disable the affected provider path without silencing unrelated notifications. Keep the check as close as possible to the provider call so work already sitting in a queue still respects the rollback.

Consider the concrete edge at 08:55, five minutes before a live class. The queue contains 600 reminders, the provider begins rejecting a subset, and the first poll reports 28 failures. Disabling immediately protects the budget but may suppress reminders because of one malformed tenant import; waiting for ten polls may push the entire queue through a damaged path. Three consecutive qualifying samples create a 60-second confirmation window after the first observation when polls are 30 seconds apart. During that window, the Node.js sender continues checking the same provider-specific flag before every attempt. On the third high sample, the worker persists one incident identity, toggles once, and emits a notification event. Already queued password resets can follow a separately documented reroute policy while low-priority reminders wait. None of those numbers proves the right production threshold. The example exposes the decisions an eval must score: false shutdown, additional failed sends, delayed urgent mail, and backlog pressure after recovery. It also shows why “errors are high” is not enough of a specification.

There is an important systems detail here. The observability surface does not provide threshold rules, phone or SMS escalation, webhook delivery, or an alert route. Polling and notification are your responsibility. That sounds less convenient, but it makes the safety contract explicit: a failed notification attempt cannot undo the mitigation, and a problem in the Slack sender cannot leave the risky delivery path enabled.

Implement the one-way controller before tuning thresholds

The worker below is written in Python even though the delivery service is Node.js. That split is intentional for a notebook-to-prod workflow: the policy can be exercised with saved JSON samples, then run as a small sidecar without changing the application runtime. It uses only GET /v1/errors/groups and POST /v1/flags/toggle/{key}. Because the error-group response fields are not specified here, the JSON paths are explicit environment configuration rather than guessed field names.

The process stores its state in SQLite. Run one replica, or move the same state transition and lock into Postgres before adding replicas. The event printed after a successful transition is the handoff to your own notification worker.

import hashlib
import json
import os
import sqlite3
import time
import urllib.error
import urllib.parse
import urllib.request
from email.utils import parsedate_to_datetime
from typing import Any

BASE_URL = os.environ["INFRAI_API_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
FLAG_KEY = os.environ.get("FLAG_KEY", "edtech-notification-provider")
GROUPS_PATH = os.environ["ERROR_GROUPS_JSON_PATH"].split(".")
COUNT_PATH = os.environ["ERROR_COUNT_JSON_PATH"].split(".")
THRESHOLD = int(os.environ.get("FAILURE_THRESHOLD", "25"))
REQUIRED_POLLS = int(os.environ.get("REQUIRED_POLLS", "3"))
POLL_SECONDS = int(os.environ.get("POLL_SECONDS", "30"))
DATABASE_PATH = os.environ.get("KILL_SWITCH_DATABASE", "kill-switch.sqlite3")


def json_path(value: Any, path: list[str]) -> Any:
    for segment in path:
        value = value[int(segment)] if isinstance(value, list) else value[segment]
    return value


def retry_delay(headers: Any, attempt: int) -> float:
    retry_after = headers.get("Retry-After") if headers else None
    if retry_after:
        try:
            return max(0.0, float(retry_after))
        except ValueError:
            parsed = parsedate_to_datetime(retry_after)
            return max(0.0, parsed.timestamp() - time.time())
    return min(float(2**attempt), 30.0)


def request_json(
    method: str, path: str, *, idempotency_key: str | None = None
) -> Any:
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Accept": "application/json",
    }
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    for attempt in range(5):
        request = urllib.request.Request(
            f"{BASE_URL}{path}", headers=headers, method=method
        )
        try:
            with urllib.request.urlopen(request, timeout=15) as response:
                body = response.read().decode("utf-8")
                return json.loads(body) if body else {}
        except urllib.error.HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code == 429 and attempt < 4:
                time.sleep(retry_delay(error.headers, attempt))
                continue
            raise RuntimeError(
                f"API request failed with HTTP {error.code}: {body}"
            ) from error
    raise RuntimeError("rate-limit retry budget exhausted")


def failure_count(document: Any) -> int:
    groups = json_path(document, GROUPS_PATH)
    if not isinstance(groups, list):
        raise ValueError("ERROR_GROUPS_JSON_PATH must resolve to a list")

    total = 0
    for group in groups:
        count = json_path(group, COUNT_PATH)
        if not isinstance(count, int) or isinstance(count, bool) or count < 0:
            raise ValueError("ERROR_COUNT_JSON_PATH must resolve to non-negative integers")
        total += count
    return total


def connect() -> sqlite3.Connection:
    database = sqlite3.connect(DATABASE_PATH)
    database.execute(
        "CREATE TABLE IF NOT EXISTS controller ("
        "flag_key TEXT PRIMARY KEY, qualifying_polls INTEGER NOT NULL, "
        "disabled INTEGER NOT NULL, incident_key TEXT)"
    )
    database.execute(
        "INSERT OR IGNORE INTO controller VALUES (?, 0, 0, NULL)",
        (FLAG_KEY,),
    )
    database.commit()
    return database


def sample(database: sqlite3.Connection, failures: int) -> tuple[int, bool, str | None]:
    row = database.execute(
        "SELECT qualifying_polls, disabled, incident_key "
        "FROM controller WHERE flag_key = ?",
        (FLAG_KEY,),
    ).fetchone()
    if row is None:
        raise RuntimeError("controller state is missing")

    qualifying = row[0] + 1 if failures >= THRESHOLD else 0
    disabled = bool(row[1])
    incident_key = row[2]
    if qualifying >= REQUIRED_POLLS and not disabled and incident_key is None:
        seed = f"{FLAG_KEY}:{time.time_ns()}".encode("utf-8")
        incident_key = hashlib.sha256(seed).hexdigest()

    database.execute(
        "UPDATE controller SET qualifying_polls = ?, incident_key = ? "
        "WHERE flag_key = ?",
        (qualifying, incident_key, FLAG_KEY),
    )
    database.commit()
    return qualifying, disabled, incident_key


def disable(database: sqlite3.Connection, incident_key: str) -> None:
    escaped_key = urllib.parse.quote(FLAG_KEY, safe="")
    request_json(
        "POST",
        f"/flags/toggle/{escaped_key}",
        idempotency_key=incident_key,
    )
    database.execute(
        "UPDATE controller SET disabled = 1 WHERE flag_key = ?",
        (FLAG_KEY,),
    )
    database.commit()


def main() -> None:
    database = connect()
    while True:
        groups = request_json("GET", "/errors/groups")
        failures = failure_count(groups)
        qualifying, disabled, incident_key = sample(database, failures)

        if qualifying >= REQUIRED_POLLS and not disabled and incident_key:
            disable(database, incident_key)
            print(
                json.dumps(
                    {
                        "event": "notification_kill_switch_disabled",
                        "flag_key": FLAG_KEY,
                        "failures": failures,
                        "incident_key": incident_key,
                    }
                ),
                flush=True,
            )
            return

        time.sleep(POLL_SECONDS)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Set ERROR_GROUPS_JSON_PATH to the array location and ERROR_COUNT_JSON_PATH to the count within each group after inspecting the current schema. An empty path can be represented by an empty environment value only if your configuration loader turns it into an empty list; the sample deliberately demands an explicit contract. This keeps a response-shape assumption out of source code, which is exactly where a quiet schema mismatch would otherwise become a rollback decision.

Every request has an explicit method and Bearer authentication from INFRAI_API_KEY. A 429 honors Retry-After or uses bounded exponential delay. Other unsuccessful responses surface their status and body. The write carries a stable idempotency key created and committed before the toggle, so a rate-limit retry uses the same operation identity rather than creating a second incident.

Short code can still hide a consequential choice. toggle is safe here only because the durable disabled bit and singleton execution permit one transition. If several workers can race, put the state row behind a database transaction and lock; don't hope that timing will serialize a safety action.

Test rollback policy against labeled notification sequences

Test the controller as a state machine before connecting it to live traffic. A useful labeled sequence is 0, 4, 28, 31, 29: with a threshold of 25 and three required polls, it should disable on the final sample. The sequence 28, 4, 31, 29 should remain enabled because the clean sample breaks the run. Neither case should auto-enable later. These values demonstrate behavior; they are not measured production guidance.

This is where an eval-driven habit pays off. Build a small corpus from notification classes and expected decisions: password resets, class reminders, instructor announcements, and low-priority digests. Score a false disable more heavily for the urgent path, then score a late disable more heavily for a provider that can amplify duplicate sends. The policy is deterministic, so every failure is inspectable. Good. An LLM may summarize the emitted incident for an operator, but it should not make the kill-switch decision unless a labeled evaluation set, bounded output schema, and deterministic fallback justify that extra uncertainty and prompt cost.

Also test the awkward inputs: a missing configured JSON path, a negative count, a malformed local database, an HTTP 429, and a restart after the incident key is committed but before the local disabled bit changes. The last case is why the operation identity must survive a restart. I would also replay an hour of real sampling cadence with time compressed; unit tests that skip scheduling often miss the exact boundary between “three samples” and “three consecutive samples.”

Rollback safety extends beyond the flag. Disabling new sends does not decide what happens to messages already in the queue. Preserve message ID, tenant ID, notification class, and intended delivery time in your own system, then choose whether each class waits, reroutes, or expires. Consumers need idempotent handling because recovery can release a backlog. A login link and a weekly digest should not inherit the same replay policy merely because they share a provider.

Choose control depth, not a brand

The right comparison is how much control-plane governance the rollback deserves. The options below are deliberately framed as proof questions because plan details and deployment choices need current verification; the table does not pretend that one product wins every edtech workload.

Option Why put it in the proof Decision that can disqualify it
Infrai One REST API covers error groups, flags, and broader backend capabilities over plain HTTP, so this Python worker needs no product SDK and another runtime can use the same contract. Its public, keyless discovery describes request and response schemas. The flags are basic: no audit trail, evaluation analytics, parent-child dependencies, or recycle bin, and clients depend on polling.
LaunchDarkly Evaluate it as a specialist feature-management control plane. Stick with a specialist when approvals, detailed change history, or richer flag evaluation are mandatory.
Unleash Include it when a self-managed flag control plane is under consideration. Account for operating and upgrading that control plane in the rollback reliability budget.
Statsig Evaluate it when feature evaluation and experimentation need to sit in the same product review. Verify that its operational change process matches the incident approval and retention policy.
Sentry Include it when grouped application errors are the primary detection input. Pairing an error specialist with a separate flag system leaves the cross-product controller under your ownership.
Datadog Evaluate it when the team wants a broader observability control plane. A separate feature-flag authority still requires a carefully governed automation boundary.
Grafana Include it when the proof centers on an existing dashboard and alerting workflow. Verify how the external flag write and its change record fit the incident process.
Better Stack Evaluate it when notification failures and worker heartbeat coverage should be reviewed together. Confirm that flag governance still has a clear owner if control stays in another product.

Infrai gives this Python worker one key for every required call and a plain REST API with no SDK to install. That contract covers 295 routes across 20 modules, while public self-describing discovery makes schema checks automatable. The catch is real. It is not suitable for compliance-sensitive change management, and I would choose a specialist flag platform when approvals, durable audit history, dependency rules, or evaluation analytics are release requirements. Deleting a flag has no recycle bin, so incident procedure should toggle it and preserve the key.

Detection depth can disqualify the combined approach too. Logs can carry trace_id and span_id for correlation, but there is no distributed tracing query or span tree. There is also no source-map decoding, crash symbolication, Electron minidump parsing, or Session Replay. Choose a specialist observability product when those artifacts are central to diagnosis, rather than stretching grouped errors into a tracing system.

Operate the switch as a safety control

Put the worker on its own health path. This platform has no synthetic check or heartbeat monitor, so a silent “the polling job never ran” failure needs a tool such as Healthchecks or an equivalent external monitor. Alert on stale successful-poll timestamps as well as delivery failures. Otherwise the mechanism intended to protect notification delivery can disappear quietly while every dashboard remains calm.

The operational review should read like prose because the dependencies form one argument. Confirm that the Node.js service checks the flag immediately before provider execution; confirm that only one controller owns the disable transition; confirm that incident state survives restarts; confirm that Slack or email delivery cannot reverse mitigation; and confirm that queue replay policy is documented per notification class. Then run the labeled sequences again whenever thresholds, polling cadence, provider routing, or JSON extraction paths change.

Don't automate re-enable by symmetry. Recovery needs evidence that the provider is healthy, the queued workload is understood, and the original failure class will not recur under backlog pressure. A reviewed manual transition is slower. That friction is useful when rollback safety is the primary decision axis.

Stop there.

References

Top comments (0)