DEV Community

mT41Gzp73rc6
mT41Gzp73rc6

Posted on

Node.js Error Tracking Code for Capturing API Failures and Safe Rollbacks

Short answer: capture Node.js API errors at the import boundary, attach run and release context, and resolve them with business-output metrics before considering a rollback.

This approach has a limit: error tracking can correlate a failed run, but it cannot decide whether an upstream empty result is legitimate without domain data. A metrics-only design is cheaper to operate, yet it loses the per-run context needed for a safe replay.

The trade-off is deliberate: this event model is not suitable for deciding freshness on its own, while a metrics-only alert is not suitable for replay diagnosis. Use the former for incident context and the latter for trend detection.

To capture and resolve Node.js API errors safely, treat the scheduled import as a business workflow rather than an exception stream. A run that returns an error is easier to operate than one that quietly produces zero records. The rollback decision should depend on a small set of correlated signals, not on an error counter alone: the import run must be identified, its expected output must be measurable, and the alert must carry enough context to distinguish a bad release from an upstream empty result.

The example below uses a generic Python-shaped handler because the important boundary is the event contract, not a particular tracking service. The same fields can be emitted from a Node.js API or worker.

I have learned this distinction in email, SMS, and OTP flows: a transport retry can look healthy while delivery is still missing. The importer needs the equivalent business-level signal.

What should count as an API error?

For a fintech import, “API error” is too broad to be an operational rule. A timeout, a 401 response, a valid empty page, a malformed payload, and a database commit failure have different owners and different rollback consequences. Put them in one bucket and the alert becomes noisy precisely when the team needs to make a careful deployment decision.

I use three layers of evidence:

  1. The request outcome: status class, timeout, parse result, and retry count.
  2. The business result: records accepted, records rejected, and the import’s expected freshness window.
  3. The deployment context: release identifier, importer version, and whether the run is a replay.

A valid response containing no new transactions may be normal during a quiet interval. A 200 response followed by zero accepted records for six consecutive scheduled runs is a different signal. The distinction belongs in the event model, where it can be tested, searched, and reviewed.

OpenTelemetry describes metrics as a signal for recording measurements over time, which is useful here: an exception is an event, while “accepted records per completed run” and “age of the newest successful import” are measurements. Use both.

from dataclasses import dataclass
from typing import Optional


@dataclass
class ImportOutcome:
    run_id: str
    importer: str
    release: str
    status_code: Optional[int]
    records_seen: int
    records_accepted: int
    retry_count: int
    error_kind: Optional[str]
    replay: bool


def classify(outcome: ImportOutcome) -> str:
    if outcome.error_kind in {"timeout", "connection", "rate_limit"}:
        return "transport_failure"
    if outcome.error_kind in {"parse", "schema"}:
        return "contract_failure"
    if outcome.error_kind == "commit":
        return "persistence_failure"
    if outcome.status_code is not None and outcome.status_code >= 500:
        return "upstream_failure"
    if outcome.records_seen == 0 and outcome.records_accepted == 0:
        return "empty_result"
    return "completed"
Enter fullscreen mode Exit fullscreen mode

The classification is intentionally boring. Boring labels are easier to aggregate than exception strings, and they avoid putting sensitive request bodies into an error tracker. Store a provider request identifier or a hash where it helps correlation; do not make raw account data part of the diagnostic payload.

How do you capture enough context without leaking financial data?

Capture at the boundary where the request, import run, and release are all known. A low-level HTTP wrapper can report a timeout, but it usually cannot tell whether the failed call was the first page, a replay, or a non-critical enrichment request. The importer can.

A useful event has stable dimensions and a small set of carefully selected attributes:

  • run_id, job_name, and schedule_slot identify the execution.
  • release and importer_version connect the failure to a change.
  • provider identifies the external dependency without storing a token.
  • status_code, retry_count, and duration_ms describe the request.
  • records_seen and records_accepted describe the business effect.
  • replay prevents a backfill from looking like a fresh scheduled run.

Do not attach authorization headers, full URLs with query parameters, payloads, or customer identifiers by default. Redaction is not an afterthought in an OTP or payment-adjacent system; the diagnostic event is another data product with retention, access, and deletion requirements. Keep the event useful enough to group failures, then send the detailed payload to a controlled log path only when an approved investigation requires it.

def error_event(outcome: ImportOutcome, duration_ms: int) -> dict:
    return {
        "kind": classify(outcome),
        "run_id": outcome.run_id,
        "job_name": "ledger_import",
        "importer": outcome.importer,
        "release": outcome.release,
        "status_code": outcome.status_code,
        "records_seen": outcome.records_seen,
        "records_accepted": outcome.records_accepted,
        "retry_count": outcome.retry_count,
        "duration_ms": duration_ms,
        "replay": outcome.replay,
    }
Enter fullscreen mode Exit fullscreen mode

The event should be emitted once per logical run, even when the transport layer retries. Otherwise, one upstream timeout becomes three identical incidents and the team may mistake retry volume for independent failures. Use the run ID as the idempotency key for event storage or aggregation.

When should a missing import trigger a rollback?

A rollback is a deployment action, not an alert severity. I would not roll back because one scheduled request timed out, and I would not roll back because a provider returned a legitimate empty page. Both can recover on the next run.

The rollback candidate is a release-correlated pattern with a business consequence: after a release, completed runs show a sustained drop in accepted records, the failure class is repeatable, and the previous release is known to have produced valid output under comparable input. That last condition matters. If the upstream feed is also degraded, rolling back the importer only changes which version receives the same bad input.

A compact decision record can make this explicit:

def rollback_candidate(
    *,
    runs_after_release: int,
    empty_or_failed_runs: int,
    release_changed: bool,
    prior_release_healthy: bool,
    upstream_known_degraded: bool,
    is_replay: bool,
) -> bool:
    sustained_failure = (
        runs_after_release >= 3
        and empty_or_failed_runs >= 3
    )
    return (
        release_changed
        and sustained_failure
        and prior_release_healthy
        and not upstream_known_degraded
        and not is_replay
    )
Enter fullscreen mode Exit fullscreen mode

The numbers here are policy placeholders, not universal thresholds. Three failed slots may be too slow for a market-open feed and too sensitive for a daily reconciliation. Set the window from the business freshness objective, then test it against ordinary empty periods, provider maintenance, rate limits, and replay traffic.

The important property is reversibility. Before enabling an automated rollback, record the release that will be restored, preserve the failed run IDs, and make the action idempotent. A rollback should also stop creating new duplicate imports; it must not merely move traffic to an older worker while leaving the same queue and cursor state untouched.

How do you resolve the error after the alert fires?

Resolution starts with grouping, not with restarting the job. Search by run_id to inspect one execution, then group by release, importer, and kind to determine scope. Compare the newest successful import timestamp with the schedule slot. Finally, inspect accepted-record counts before and after the release boundary.

The common paths are distinct:

  • Transport failure: keep the run pending, apply bounded retries with jitter, and avoid advancing the source cursor until a confirmed response is processed.
  • Contract failure: quarantine the payload, preserve the cursor, and require a schema review before replay.
  • Persistence failure: verify transaction boundaries and idempotency before replaying; a second attempt must not duplicate accepted records.
  • Empty result: record the successful request and wait for the freshness policy unless the empty condition crosses the agreed threshold.

A retry is not resolution. It is a controlled attempt to obtain a better outcome. For rate limits, honor the provider’s signal when available and cap the total retry window so the importer does not conceal a missed schedule. For parse failures, retries usually repeat the same defect and add noise.

This is where an error-tracking record and a metric reinforce each other. The event explains one failed run. The metric shows whether the system is recovering. Track run completion, accepted records, and freshness separately; a rising completion count can coexist with zero useful output.

A rollout that keeps the rollback path credible

Ship the event contract before shipping an automated action. During the first release, emit events and metrics, but route the decision to a human review queue. Sample normal completions aggressively if volume is high, while retaining every failure and every state transition that affects freshness.

Then replay historical edge cases: an empty provider result, a burst of 429 responses, a malformed page after a cursor advance, a database timeout after commit, and a backfill running beside the schedule. Assert that each case produces one logical run, the expected classification, and the correct rollback recommendation. Include redaction tests; an event that accidentally contains an access token is a release failure.

After the signal has earned trust, enable a narrowly scoped action for one importer and one release path. Keep a manual stop control outside the failing deployment. The stop control matters because an automation that cannot be disabled during a provider incident is not rollback-safe.

The final operating rule is concise: capture errors at the import boundary, measure business output separately, and automate rollback only when a release-linked failure is sustained, attributable, and reversible. That approach gives the on-call engineer a useful answer to “what failed?” without pretending that every empty response is a code defect.

Sources

Top comments (0)