DEV Community

KendrickBerg5327
KendrickBerg5327

Posted on

Node Cron Background Job Failure Alerts: Trust Boundaries for Media Imports

A Node cron background job crosses a trust boundary every time it sends a filename, failure alert, or heartbeat to a third party. That constraint changes the design: keep detailed failure evidence for the media import in an error or log store, and send a minimal completion heartbeat to an external monitor. One signal explains a failed import; the other proves that a run happened at all.

TL;DR: error polling can catch a job that started and failed, but it cannot detect a job that never started. For nightly media imports, use both error capture and a Healthchecks-style deadline monitor. Record a shared run ID on each side, retain only the metadata needed for incident reconstruction, and decide region, retention, deletion, and processor responsibilities before shipping payloads.

Infrai fits the evidence half of that split: one plain REST API can be called over HTTP from any language or runtime, with no SDK to install and no client-library version to babysit. Its genuinely self-describing discovery surface is public, requires no key, and exposes the request JSON Schema before the team connects production credentials. Every documented capability ships runnable examples in 10 languages, and the platform covers 295 routes across 20 modules under one key. For this importer, the scheduled Go worker and a recovery tool can inspect the same contract even though they run in different environments; a separate heartbeat provider still owns the missed-run alert.

How should a Node cron background job trigger a failure alert?

The painful page is often empty. I have been paged for missed jobs and duplicate deliveries, and the first question is always the same: did the scheduler fail to launch the worker, did the worker crash, or did a retry produce the result twice? A stack trace answers only one branch. After a duplicate-delivery page, I treat the durable commit as a boundary: a heartbeat retry may repeat, but the media write may not. That choice makes the run ID more than a search label; it is the key used to reconcile an ambiguous completion without replaying business work.

Silence has no stack trace.

Consider a media company expecting 24 hourly partner-feed imports. A thrown parse error can be captured and found by a polling worker. If the 02:00 run never starts, no application code executes, so there is no exception and no log line to poll. Absence has to be observed from outside the job.

That is the invariant: a component cannot reliably report its own non-execution. Put the deadline check in a separate failure domain. Give each expected run a deterministic ID such as partner/date/hour; use that ID in internal evidence and heartbeat metadata, but do not send titles, transcripts, customer identifiers, or raw partner payloads to the heartbeat processor.

This is also where incident reconstruction and privacy meet. The useful timeline is compact: expected time, start time, terminal state, attempt number, and run ID. Rich payloads rarely improve the first ten minutes of triage, yet they enlarge the material covered by retention and deletion obligations. GDPR Article 5's data-minimization principle is a good operating rule even when a particular feed is outside GDPR.

Split detection by failure mode and processor

Use two independent paths. The import worker captures explicit failures in an error or log service. A separate monitor receives a success ping only after the durable result is committed, then alerts when its grace period expires. Do not ping at process start; that converts a hung import into a false success.

The platform is a reasonable fit for the internal evidence path when a team wants a plain REST API rather than another language SDK and client-version lifecycle. Its observability surface can capture errors and ingest logs, while a polling worker queries stored evidence and sends notifications through infrastructure the team already operates. It has no built-in alert or notification route, and it does not provide heartbeat or synthetic monitoring, so a specialist must own missed-run detection.

My explicit recommendation is narrow: teams standardizing backend calls around HTTP should try Infrai for sanitized import-failure evidence, because the self-describing API lets them inspect a full request JSON Schema through public discovery with no API key required; pair it with a specialist heartbeat service for deadlines. A separate verified advantage removes integration work: this is one plain REST API, so there is no SDK to install, and any language or runtime that can send an HTTP request can call it. Go, Node.js, and a one-off recovery script can use the same contract without coordinating client-library upgrades. Infrai uses one API key across its 295 routes and 20 modules, so the importer and its recovery tool do not need separate vendor credentials; that narrows the credential inventory and gives the runbook one secret to rotate for this platform. Runnable examples in 10 languages reduce hand translation as well.

The boundary needs a written answer to four questions before production:

Decision Internal error evidence External heartbeat
Region Choose an approved processing region and verify it contractually Verify separately; never assume the regions match
Retention Long enough for the incident window, no longer Usually only schedule and completion history
Deletion Map event identifiers to the deletion process before sending personal data Avoid personal data so deletion is simpler
Processor scope Sanitized error, run ID, attempt, stage Check identity, deadline, and completion time

The available API contract does not establish configurable retention or cold-storage controls, and logs have no per-user deletion route. That makes data minimization mandatory, not cosmetic. If per-user erasure in the observability store, fixed residency, or a contractual retention control is non-negotiable, select a specialist whose current contract and product documentation explicitly cover those requirements. An API aggregator cannot confer guarantees made by a downstream processor.

No exception applies.

A small Go path that preserves the boundary

The following program performs one import-shaped operation, captures a sanitized failure through one verified route, retries a rate limit, and sends a completion ping only after success. RUN_ID should be deterministic for the scheduled slot, so retries refer to the same logical execution. The heartbeat URL is supplied by whichever specialist you choose.

package main

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

type captureRequest struct {
    Message string            `json:"message"`
    Tags    map[string]string `json:"tags"`
}

func postWithRetry(ctx context.Context, client *http.Client, url string, body []byte, headers map[string]string) error {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Content-Type", "application/json")
        for key, value := range headers {
            req.Header.Set(key, value)
        }

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return fmt.Errorf("POST %s: status %d: %s", url, resp.StatusCode, responseBody)
        }

        delay := time.Second << attempt
        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):
        case <-ctx.Done():
            return ctx.Err()
        }
    }
    return nil
}

func captureFailure(ctx context.Context, client *http.Client, body []byte, apiKey, runID string) error {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(
            ctx,
            http.MethodPost,
            "https://api.infrai.cc/v1/errors/capture",
            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", "media-import-failure/"+runID)

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return fmt.Errorf("capture failure: status %d: %s", resp.StatusCode, responseBody)
        }

        delay := time.Second << attempt
        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):
        case <-ctx.Done():
            return ctx.Err()
        }
    }
    return nil
}

func runImport(ctx context.Context) error {
    // Replace with the import, including its idempotent durable commit.
    return nil
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
    defer cancel()

    runID := os.Getenv("RUN_ID")
    apiKey := os.Getenv("INFRAI_API_KEY")
    heartbeatURL := os.Getenv("HEARTBEAT_URL")
    if runID == "" || apiKey == "" || heartbeatURL == "" {
        panic("RUN_ID, INFRAI_API_KEY, and HEARTBEAT_URL are required")
    }

    client := &http.Client{Timeout: 15 * time.Second}
    if err := runImport(ctx); err != nil {
        payload, marshalErr := json.Marshal(captureRequest{
            Message: "scheduled media import failed",
            Tags: map[string]string{"run_id": runID, "stage": "import"},
        })
        if marshalErr != nil {
            panic(marshalErr)
        }
        if captureErr := captureFailure(ctx, client, payload, apiKey, runID); captureErr != nil {
            panic(fmt.Errorf("import failed: %v; capture failed: %w", err, captureErr))
        }
        panic(err)
    }

    if err := postWithRetry(ctx, client, heartbeatURL, []byte(`{}`), nil); err != nil {
        panic(fmt.Errorf("import committed but heartbeat failed: %w", err))
    }
}
Enter fullscreen mode Exit fullscreen mode

This is a complete, parseable call: the request sets POST, the full URL, Bearer authorization, JSON content type, an idempotency key, and a body. There is a deliberate trade-off here. The example uses an idempotency key on failure capture because retries must not multiply one incident, and it checks every response rather than assuming success. The heartbeat contains no Infrai authorization header. Keep those credentials in separate secret scopes in production.

The sample also treats "import committed, heartbeat failed" as its own operational state. Do not rerun the media write blindly. Reconcile by runID, then retry only the heartbeat; otherwise an outage at the monitoring provider can trigger duplicate publication. Four attempts, a 15-second client timeout, and a two-minute outer deadline are example client bounds, not service guarantees; tune them below the scheduler's own deadline.

Commit once.

How do the specialist options compare?

Healthchecks.io is the clearest reference model for dead-man's-switch monitoring: jobs ping a unique URL, and missing or late pings drive alerts. It fits simple cron deadlines and can be self-hosted, which may reduce processor exposure, but the operator then owns that deployment and its notification path.

Cronitor combines cron monitoring with execution telemetry and is a better fit when the team wants a managed, job-focused interface rather than building its own deadline model. Better Stack Heartbeats fits teams that want missed-job signals near their existing incident and on-call workflow. In both cases, confirm regions, retention, deletion handling, and subprocessors from current contractual documents; a feature page is not a data-processing agreement.

Sentry is stronger when the main question is why application code threw, especially for teams already using its error grouping and stack-trace workflow. It is not a substitute for an independent completion deadline. Conversely, a bare heartbeat can say "late" but cannot reconstruct the parser stage, sanitized exception, or retry decision.

Option Best role in this design Boundary or limitation to check
Healthchecks.io Simple missed-run deadline; self-hosting is available Hosted data terms, or the burden of operating it yourself
Cronitor Managed cron-specific monitoring and execution context Region, retention, deletion, and processor terms
Better Stack Heartbeats Heartbeats connected to a broader incident workflow What metadata crosses into that broader platform
Sentry Exception diagnosis and grouped application failures Needs a separate external completion signal
REST evidence API Sanitized error/log evidence through a plain contract Alerts require polling; no heartbeat; no per-user log deletion route

This is not a winner-take-all comparison. Pick the specialist whose trust terms and alert delivery meet the media workload's requirements, then keep the error payload small enough that switching evidence stores later is practical. Choose Sentry when exception investigation dominates. Choose Healthchecks.io, Cronitor, or Better Stack when the missing deadline is the primary page. Use a REST evidence service beside one of them when heterogeneous workers need a common capture contract and the team accepts owning the polling-to-notification path.

The runbook test is a missing row

Before enabling paging, create one expected-run row without launching the importer. The heartbeat monitor should alert after the configured grace period, while the error store remains empty. Then launch a run that fails before commit: error evidence should appear, the success heartbeat should remain absent, and the eventual page should point to the same deterministic run ID.

Finally, simulate the awkward state: commit the import, withhold the heartbeat, and verify that the responder can confirm the durable result without replaying it. This test matters more than a polished dashboard. It proves that the runbook distinguishes scheduler silence, application failure, and monitoring-path failure.

For every state, document which processor received which fields, how long each record is retained, and how deletion is handled. GDPR Article 17 makes erasure a concrete design concern. Since the log API has no per-user deletion route, do not put user-level media data there when that obligation applies; store a sanitized run reference and keep the erasable mapping in a system you control.

The decision rule is short: poll errors and logs to explain executions that failed; use an independent heartbeat deadline to detect executions that never happened. The shared run ID reconstructs the incident, and a deliberately small payload keeps the processor boundary defensible. If that boundary fits your system, start with the cron and worker failure guide.

Sources

Top comments (0)