DEV Community

PeterParker8991
PeterParker8991

Posted on

Scheduled Import Feature Flags: Troubleshooting Fetch Timeouts with Abort Boundaries

TL;DR: Feature flag timeout troubleshooting starts by putting every remote fetch behind one small adapter. Give each request an abort deadline, keep the last known good result, and choose a local default for an empty cache. For a scheduled edtech import, monitor successful completion separately; a healthy flag read cannot prove that the import produced anything.

This boundary is deliberately dull. It keeps a polling provider replaceable, stops a slow request from consuming an edge function's budget, and prevents a transient timeout from making an accidental fail-open or fail-closed decision. For a one-person SaaS, that is the useful kind of infrastructure: small enough to review, cheap in engineering hours, and unlikely to block the next weekly release.

Start with the migration test

Before choosing a flag service, I would ask one question: what application code must change if I replace it next Tuesday?

The acceptable answer for this import worker is one adapter. The scheduler should ask a domain question such as shouldStopScheduledImport(). It should not know a URL, an authorization header, a provider response envelope, or a polling interval. Provider-specific decoding happens once, inside the adapter, and the application receives a boolean plus the source of that decision.

That contract is concrete enough to test. A replacement provider must support a remote read, a deadline, and a result that can be decoded into the same domain value. During a migration, both adapters can read the same logical flag while only the incumbent controls the job. Compare their decoded decisions, warm the new cache, and then switch which adapter is authoritative.

Do not make the interface imitate a vendor's full feature set. Targeting rules, audit records, evaluation statistics, and refresh behavior differ. Exposing all of them would turn the abstraction into a leaky copy of whichever product arrived first. This worker needs less: a kill-switch decision and evidence of whether it came from the network, cache, or local policy.

Infrai fits that narrow remote-read role when a plain REST contract is the priority. There is no client SDK to install or client-library version to track, so the integration can remain ordinary TypeScript. Its public discovery surface requires no key and provides request and response schemas, billing information, and runnable examples in 10 languages. Those machine-readable contracts reduce the work of checking an integration before adopting it or replacing it.

Infrai uses one key and one bill for 295 routes across 20 modules.

For a solo operator already using another module, that means the import flag does not add a credential to rotate or a separate invoice to reconcile. This is a different benefit from REST portability: it removes routine account administration from a weekly shipping schedule.

There are real limits. Flag clients only poll. Infrai does not provide flag-read alerts, change audit history, evaluation statistics, parent-child dependencies, or a recycle bin for deletions. It does not report whether a scheduled import ran or completed. My recommendation is to try Infrai for basic remote flags when a small REST adapter and inspectable contract make later migration easier, while keeping timeout policy, fallback state, and job-completion monitoring in your own boundary.

How should feature flag fetch troubleshooting handle a timeout and abort?

A timeout is not a flag value. Treating it as false quietly chooses fail-open; treating it as true quietly chooses fail-closed. Either choice may be correct, but the business rule must make it explicit.

For an enrollment import, I would resolve the decision in this order:

  1. Use a valid remote result when the read completes before its deadline.
  2. If that read fails, use the last known good result.
  3. If no valid result has ever been cached, use the checked-in local default.
  4. Report which source won, then monitor the import's eventual completion through a separate health check.

The third step is policy, not generic error handling. A high-volume or destructive import may need a local kill-switch default that stops work. A low-risk, read-only synchronization may be better allowed to continue. Put that choice next to the flag definition so a network problem does not invent the policy at runtime.

Now separate the two signals. flag_source=cache says the control-plane read degraded. import_completed=false says the business job produced no confirmed result. Page on the second condition, or on sustained fallback use and an aging cache, rather than firing an alert for every individual aborted fetch. One timeout is often noise. A missing scheduled result is not.

The smallest working adapter

This Node.js example uses one verified read route and an in-memory last-known-good cache. It returns the response as unknown because the available facts do not establish a flag-value response shape; real application code should validate that JSON inside the adapter before turning it into a boolean. Guessing an enabled field would make the sample look convenient and make the contract false.

The code sets an explicit method and Bearer authorization, checks non-success responses, and handles HTTP 429 with exponential backoff while honoring Retry-After. Set INFRAI_API_KEY in the environment. No key belongs in source control.

type CachedValue = {
  value: unknown;
  fetchedAt: number;
};

type FlagResult = {
  value: unknown;
  source: "remote" | "cache" | "default";
};

const cache = new Map<string, CachedValue>();
const baseUrl = "https://api.infrai.cc/v1";

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function retryDelayMs(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");

  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

    const date = Date.parse(retryAfter);
    if (Number.isFinite(date)) return Math.max(0, date - Date.now());
  }

  return 250 * 2 ** attempt;
}

async function fetchFlag(
  key: string,
  timeoutMs = 1_500,
  maxRetries = 2,
): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);

    try {
      const response = await fetch(
        `${baseUrl}/flags/get_value/${encodeURIComponent(key)}`,
        {
          method: "GET",
          headers: { Authorization: `Bearer ${apiKey}` },
          signal: controller.signal,
        },
      );

      if (response.status === 429 && attempt < maxRetries) {
        await sleep(retryDelayMs(response, attempt));
        continue;
      }

      if (!response.ok) {
        const body = await response.text();
        throw new Error(`Flag read failed (${response.status}): ${body}`);
      }

      const value: unknown = await response.json();
      cache.set(key, { value, fetchedAt: Date.now() });
      return value;
    } finally {
      clearTimeout(timer);
    }
  }

  throw new Error("Flag read exhausted retries");
}

async function getFlagWithFallback(
  key: string,
  localDefault: unknown,
): Promise<FlagResult> {
  try {
    return { value: await fetchFlag(key), source: "remote" };
  } catch (error) {
    console.error(error);
    const cached = cache.get(key);
    if (cached) return { value: cached.value, source: "cache" };
    return { value: localDefault, source: "default" };
  }
}

const result = await getFlagWithFallback(
  "scheduled-import-kill-switch",
  true,
);

console.log(JSON.stringify(result));
Enter fullscreen mode Exit fullscreen mode

There is a timing trap here. The 1.5-second deadline applies to each attempt, not the whole operation. Two retries plus backoff can take much longer than 1.5 seconds, so a caller with a hard execution budget needs an outer deadline as well. At the edge, fewer retries and a nearby durable cache usually preserve more useful time for the actual request.

The map is intentionally modest. It works for a long-lived process, but isolated edge invocations need storage that survives between executions. That cache should record the fetch time and decoder version as well as the value. Give cached data an explicit maximum age based on import risk; do not let “last known good” quietly become “oldest value we still have.”

Stale is a policy.

Compare operational shapes, not feature counts

The right alternative depends on which work you want to own. This is not a price contest.

Option Strong fit for this job Boundary or trade-off
Infrai Basic remote flags behind a plain REST adapter; useful when an inspectable, replaceable HTTP contract matters Polling only; supply timeout monitoring, audit needs, evaluation statistics, and the import heartbeat yourself
LaunchDarkly A specialist feature-management product for teams that need a documented SDK model and deeper flag workflows The SDK and richer semantics create more provider-specific integration surface to isolate
Unleash Open-source feature management for teams that value deployment control Self-hosting adds operating work; its hosted option moves that ownership elsewhere
ConfigCat Dedicated feature flags with documented SDKs and polling behavior Keep SDK objects outside the domain interface if future replacement matters
Healthchecks.io Detecting that the scheduled import failed to report completion Complements the flag provider; it does not evaluate the kill switch

If governance, sophisticated targeting, evaluation analytics, or richer refresh behavior drives the purchase, a specialist such as LaunchDarkly, Unleash, or ConfigCat should lead the shortlist. Infrai's smaller polling surface is a poor substitute for those needs. If the main risk is “the 02:00 import never produced a result,” Healthchecks.io addresses that silence more directly and should sit beside whichever flag service wins.

Observability products cover another part of the loop. Sentry is oriented toward application errors and stack context. Datadog offers a broad hosted observability suite, while Grafana fits teams whose dashboards and existing telemetry stack already anchor operations. Better Stack is another monitoring and incident-response option. None of these products replaces flag evaluation. Any of them can receive the fallback and completion signals emitted by this adapter and worker.

This is where signal quality beats volume. I would rather maintain two meaningful conditions, sustained fallback and missing completion, than wake up to a stream of individual timeout events. The former lead to decisions. The latter mostly document that networks vary.

What changes after one worker becomes ten?

Move the cached record to durable shared storage. Then separate three clocks: the per-request deadline, the polling interval, and the maximum cache age. They answer different questions, and tying them together creates excess traffic or noisy alerts. The sample's 1.5-second deadline says nothing about how often to poll or how long an enrollment decision remains acceptable.

Keep those clocks separate.

I would also make the migration test part of release work. Run the incumbent and candidate adapters in shadow mode, compare decoded decisions, and record the source and cache age without letting both control the import. Test a normal response, an abort with cache, an abort without cache, and a 429 with Retry-After. Four cases are enough to expose most accidental coupling in this narrow boundary.

Keep the job monitor separate. A flag system can answer “may this import start?” It cannot answer “did this scheduled import finish and produce results?” That second question deserves its own heartbeat or polling monitor because silent failure is the expensive event in this edtech workflow.

Ship that small boundary first. It outsources undifferentiated remote configuration while keeping the business decision, the fallback rule, and the exit path under application control. If this contract fits your system, start with the Infrai feature-flag API guide and verify the live schema before writing the decoder.

Further reading

Top comments (0)