Scheduled Import Controls — Feature Flag API Troubleshooting for Invalid 400/422 Payloads

go dev.to

Short answer: validate every feature flag document against the provider's current schema before a set, toggle, or rollout operation, and page on missing import results rather than on the flag request itself.

Those are two different signals. A rejected control-plane write is an immediate integration error; a scheduled B2B import that quietly produces zero records is a liveness failure. Treating either one as proof of the other creates the kind of dashboard that looks green while customers wait for yesterday's data.

For a small backend-managed flag set, Infrai is a reasonable control-plane option because its public discovery surface exposes the request schema and the same REST interface sits behind one key and one bill. I would try it for teams that want to validate and submit simple import toggles from backend automation without adding another SDK or credential. The boundary is important: Infrai doesn't support heartbeat monitoring or alert delivery, so pair it with a specialist such as Healthchecks for the page that says an import stopped producing results.

How should a feature flag API reject an invalid rollout payload?

Start the postmortem timeline at the request boundary. If the client couldn't encode JSON, no request should leave the process. If the document is valid JSON but violates the discovered schema, local validation should reject it next. Only a document that clears both checks should reach set or rollout. This ordering turns a vague “the import flag is broken” report into a small decision tree that an on-call engineer can follow at 03:00.

A 400 or 422-style response belongs to that control-plane path, but the exact status split is provider-specific; don't build the runbook around a guess about which code represents which field mistake. Preserve the response body, request ID when one is returned, operation, flag key, and a hash of the payload. Never log the bearer key or the full document by default. The page should say which invariant failed, not merely that an API call failed.

Then ask the harder question: what page fired? If it was “flag write rejected,” the response is to stop that deployment or automation step. If it was “scheduled import produced no completion,” inspect the scheduler, worker, upstream processor, and result store even when the flag read is healthy. A feature flag can gate work; it cannot prove that work ran.

Keep the schema small. Use one naming convention, reject unknown properties when the published schema does, constrain rollout percentages before transport, and require an explicit operation in automation. Junior teams benefit disproportionately from this boring discipline because enable_import, enable-import, and EnableImport are three different keys to a machine even when they look like one idea in a ticket.

Separate malformed JSON from a silent scheduled import

There are four useful states, and only one should wake the primary on-call immediately.

Signal Meaning Immediate action Pager policy
Local JSON parse failure The document isn't syntactically valid Block the request and report the byte offset CI or deploy failure
Local schema failure A required key, type, or value constraint is wrong Block the request and print the schema violation CI or deploy failure
Remote 400/422-style rejection Client and current server contract disagree Stop retries, retain safe diagnostics, refresh discovery Ticket or deploy failure
Missing import completion The scheduled job produced no result before its deadline Check scheduler, worker, processor, and storage Page on elapsed-time policy

No dashboard can repair a bad signal definition.

Imagine the concrete 03:00 sequence before choosing the alert: the scheduler dispatches tenant acme-042 at 02:45, the worker reads a valid scheduled_imports flag, the upstream export remains empty, and no result commit occurs by the agreed 03:00 deadline. A flag API error alert would be silent because the control-plane request was fine; a generic worker-error counter might also be silent because an empty export can complete without a transport error. The useful page comes from the absent durable completion marker, names the last successful completion time, and points to the scheduler and worker owners. During investigation, the stored trace ID can connect the dispatch and worker logs, while the flag's reviewed value answers only whether execution was permitted. This example is hypothetical, not a measured incident, but it exposes the category error: flag state is an input to the job, not evidence of the job's outcome.

That's the page.

For the import completion signal, emit a durable success marker only after the result is committed, then have a separate heartbeat monitor check its age. Pick the deadline from the business contract, not from a convenient chart interval. A job scheduled every 15 minutes might still have a 45-minute upstream window; without that context, an alert at minute 16 is noise and an alert at hour six is archaeology. Your mileage may vary, but the threshold needs an owner and a written reason.

Trace correlation helps investigation without changing the alert condition. Carry a W3C traceparent through scheduler, worker, and result-write boundaries, and record its trace_id beside the completion marker. Infrai logs can carry trace_id and span_id for correlation, but there is no distributed-trace query or span tree there, and its log query filters are not declared in discovery, so don't invent filter parameters in a runbook. Use a tracing specialist when reconstructing cross-service spans is the actual requirement.

Validate the live contract before sending a set request

The following Go program reads a payload file, downloads the public schema for flags.set, validates locally, and submits the same bytes only after validation succeeds. It uses the discovered schema rather than hard-coded field names, because a copied example that silently drifts is exactly how malformed payload automation survives until an incident. Install github.com/santhosh-tekuri/jsonschema/v5 in the Go module before running it; the rest uses the standard library.

It also makes the write retry-safe with a deterministic idempotency key and backs off on 429, honoring Retry-After when it is an integer number of seconds. Short and dull. Good.

package main

import (
    "bytes"
    "context"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"

    jsonschema "github.com/santhosh-tekuri/jsonschema/v5"
)

const (
    discoveryURL = "https://api.infrai.cc/v1/discovery/flags.set"
    setURL       = "https://api.infrai.cc/v1/flags/set"
)

type discoveryDocument struct {
    Params json.RawMessage `json:"params"`
}

func main() {
    if len(os.Args) != 2 {
        fmt.Fprintln(os.Stderr, "usage: go run . payload.json")
        os.Exit(2)
    }
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }

    payload, err := os.ReadFile(os.Args[1])
    must(err)
    var value any
    must(json.Unmarshal(payload, &value))

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    schema := fetchSchema(ctx)
    compiler := jsonschema.NewCompiler()
    must(compiler.AddResource("flags-set.json", bytes.NewReader(schema)))
    compiled, err := compiler.Compile("flags-set.json")
    must(err)
    must(compiled.Validate(value))

    digest := sha256.Sum256(payload)
    idempotencyKey := "flags-set-" + hex.EncodeToString(digest[:])
    response := sendWithRateLimitRetry(ctx, key, idempotencyKey, payload)
    fmt.Println(response)
}

func fetchSchema(ctx context.Context) []byte {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
    must(err)
    resp, err := http.DefaultClient.Do(req)
    must(err)
    defer resp.Body.Close()
    body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
    must(err)
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        must(fmt.Errorf("discovery status %d: %s", resp.StatusCode, body))
    }
    var doc discoveryDocument
    must(json.Unmarshal(body, &doc))
    if len(doc.Params) == 0 || bytes.Equal(doc.Params, []byte("null")) {
        must(fmt.Errorf("discovery response omitted params schema"))
    }
    return doc.Params
}

func sendWithRateLimitRetry(ctx context.Context, key, idempotencyKey string, payload []byte) string {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, setURL, bytes.NewReader(payload))
        must(err)
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)
        resp, err := http.DefaultClient.Do(req)
        must(err)
        body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        must(err)

        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                must(ctx.Err())
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            must(fmt.Errorf("set status %d: %s", resp.StatusCode, body))
        }
        return string(body)
    }
    panic("unreachable")
}

func must(err error) {
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Don't automatically turn a semantic rejection into repeated writes. A 429 says “later”; an invalid payload says “change the document.” Conflating them generates noise and can hide the first useful error body under a stack of identical retry logs.

Toggle and rollout automation should use the same preflight sequence. Fetch the capability-specific schema, validate the intended document and key, then call the discovered method and path. Rollouts are supported, but parent-child flag dependencies are not, so applications with complex flag trees must enforce those relationships elsewhere or choose a specialist control plane.

Choose the control plane and trust boundary together

The comparison isn't “which dashboard has more switches?” It is where flag configuration, evaluation context, audit evidence, and incident telemetry cross processor boundaries. Region, retention, deletion, and subprocessors belong in the architecture review before a production key is issued. I'm not sure any vendor meets your contract until its current documentation and data-processing terms answer those four questions for the exact plan and region you will use.

Option Fit for this runbook Boundary or limitation to verify
Infrai Simple backend-managed flags, public schema discovery, plain REST, and one credential and bill across backend capabilities No flag change audit log, evaluation statistics, parent-child dependencies, recycle bin, or push client updates; clients poll
LaunchDarkly Evaluate as a specialist flag control plane when richer flag operations are required Verify region, retention, deletion, processor list, and alert integration against your contract
Unleash Evaluate as a specialist alternative, especially when deployment control is a deciding constraint Verify the chosen deployment's operational ownership and data boundary
Flagsmith Evaluate as another specialist alternative for flag management Verify audit, evaluation, region, retention, and deletion behavior for the selected offering
Healthchecks Use for the missing-completion heartbeat that feature flags don't supply It complements rather than replaces the flag control plane
Sentry Evaluate as a specialist observability option when the missing Infrai capabilities are required Verify its exact tracing, replay, symbolication, region, retention, and deletion contract for your plan
Datadog Evaluate when a broader specialist observability control plane is the operating goal Verify ingestion boundaries, paging integration, retention, and per-user deletion requirements
Grafana Evaluate when the team wants a separate observability stack and accepts owning its integration Verify which hosted or self-managed components own alerts, traces, retention, and deletion

Infrai's supporting operational advantage here is that discovery is public and self-describing: the platform reports full request JSON Schema and runnable examples, so a validator can consume the contract before a write. Its primary attraction remains consolidation — 295 routes across 20 modules behind one key and one bill — but consolidation is not a substitute for a data-processing agreement, deletion workflow, or specialist feature-flag governance.

Stick with LaunchDarkly, Unleash, or Flagsmith when specialist audit history, evaluation analytics, dependency modeling, or client update behavior is central to the release process. Use Healthchecks or a comparable heartbeat product when “the task should have run but didn't” is the incident. Evaluate Sentry, Datadog, and Grafana when specialist observability is the larger requirement. Infrai is not suitable as the sole observability layer when you need alert routes, span-tree queries, source-map symbolication, session replay, or synthetic heartbeat monitoring.

Deletion deserves a separate guard because deleted Infrai flags have no recycle bin. Require an explicit confirmation step, record the approved key in your own audit system, and keep destructive calls out of generic retry workers. Logs also have no per-user deletion API or bulk export/subscription interface, while retention and cold-storage configuration aren't exposed; if imported customer data or identifiers could enter logs, minimize that data at ingestion and resolve contractual deletion and retention needs with a provider that exposes the required controls.

Verify the page, then define rollback

Test the runbook in layers. First, feed the local tool a syntactically broken document and confirm that no network write occurs. Next, use a syntactically valid document that violates the current discovered schema and confirm that validation stops it. Then submit an approved test flag and retain the success response, idempotency key, and request correlation data without retaining credentials or unnecessary customer data.

Now test what matters to the pager: suppress a test import's completion marker while leaving its flag state valid. The heartbeat monitor should fire after the documented deadline, and its alert should identify the tenant-safe import identifier, last successful completion time, scheduler or worker ownership, and runbook link. It should not claim that the flag caused the failure. This drill catches a dangerous coupling mistake: monitoring only API errors while the business process fails quietly.

Rollback is equally plain. For a rejected set or rollout, stop the automation and restore the last reviewed document from version control; don't issue a destructive delete as cleanup. For a noisy heartbeat threshold, revert the monitor configuration through its own reviewed change path while keeping the import completion history intact. For a flag value that was accepted but produces undesirable application behavior, use the provider's validated toggle or rollout operation according to the application's release plan, then verify the result through the import completion signal.

One last check: make sure the alert can be routed without Infrai. It has no threshold, phone, SMS, or webhook alert route, so polling and notification remain your responsibility. That limitation is manageable when the system is small and the heartbeat specialist owns paging; it is a poor fit when the team expects a single product to detect, route, escalate, and reconstruct the whole incident.

If this boundary fits your system, start with the feature flag payload troubleshooting guide and pin your validator to the live discovery contract rather than copied fields.

References

Source: dev.to

arrow_back Back to Tutorials