Skip to main content
In production, errors are not exceptional — they’re expected. Networks fail. APIs return 5xx. Rate limits get hit. Validation errors surface from edge-case data. The question isn’t whether errors will happen but how your integration handles them when they do. A well-designed error-recovery system distinguishes transient from permanent failures, retries the right things at the right times, isolates failures so one bad record doesn’t poison the queue, and preserves enough audit trail to debug after the fact. This page covers the recovery patterns: error classification, retry strategies, circuit breakers, dead-letter queue management, and the specific Volunteer concerns (no idempotency keys, limited transactional semantics) that shape the right approach.

Principle 1: classify before you react

Not all errors are equal. Before deciding what to do about one, classify it: The wrong classification leads to the wrong action. Retrying a 422 indefinitely just wastes API budget; not retrying a 503 produces unnecessary sync gaps.

A classification helper

JavaScript
The classifier is the foundation of every retry / surface decision downstream.

Principle 2: retry with exponential backoff + jitter

For retryable errors, don’t retry immediately. Wait, retry, wait longer, retry again. The pattern:
JavaScript

Why exponential

Linear backoff (1s, 2s, 3s) is too aggressive on persistent failures. Exponential (1s, 2s, 4s, 8s, 16s) gives the system time to recover between attempts while not waiting forever on transient blips.

Why jitter

Without jitter, multiple workers experiencing the same failure all retry at exactly the same intervals — producing a thundering herd that overwhelms the recovering API. Random jitter spreads out the retry attempts.

Why Retry-After

For 429, the API explicitly tells you when to retry. Respect it. Don’t compute your own backoff; the API knows when it’ll be ready.

Bounded retries

After max retries, stop and surface. Indefinite retries hide systemic problems.

Principle 3: per-record isolation in batches

When processing a batch (a poll cycle, a backfill page), don’t let one bad record fail the whole batch:
JavaScript
Per-record try/catch means a single 422 on user #47 doesn’t stop processing of users #48-#150.

Checkpoint advancement with partial failures

This is subtle: if some records in a batch succeed and others fail, advancing the checkpoint past the failed ones means they’re “skipped” forever (next poll won’t re-see them). Two approaches: Approach A: advance checkpoint only to the most-recent succeeded record:
JavaScript
This works if you sort by updated_at ascending. But records with the same updated_at (rare) might be re-processed. Approach B: advance fully, but capture failures in DLQ:
JavaScript
Approach B is more common. The DLQ becomes the authoritative source of “things that failed and need re-processing.”

Principle 4: dead-letter queue management

Failures go to a DLQ. But the DLQ isn’t self-cleaning — it needs its own processing.

DLQ schema

DLQ processor

JavaScript
Run this on a schedule (every 5 minutes or so). The DLQ self-heals over time as transient errors clear up.

Permanent failures

After 10 retries spread over hours/days, give up. Mark for manual review:
JavaScript
Operators investigate; usually the resolution is either fix the data, fix the integration code, or accept the loss.

Principle 5: circuit breakers for cascade prevention

When the API or a downstream system is broken, retrying makes things worse — wasting requests on a system that can’t respond. A circuit breaker detects sustained failure and pauses operations:
JavaScript

How it works

The pattern prevents thundering-herd retries against a broken system. When VOMO is having issues, the integration pauses, then probes carefully, then resumes.

Per-customer vs. global breakers

For multi-tenant integrations:
  • Global breaker on the VOMO API: opens when VOMO itself is down — affects all customers
  • Per-customer breaker on the external destination: opens when a specific customer’s destination is failing — isolates the impact
JavaScript
A customer with a broken Salesforce destination doesn’t block another customer with a working HubSpot.

Principle 6: idempotency in the absence of API support

VOMO’s API doesn’t support idempotency keys (no Idempotency-Key header). This means:
  • A retried POST /users with the same payload upserts (safe; the email match handles idempotency)
  • A retried POST /groups creates a new Group (not safe — produces duplicates)
  • A retried DELETE /groups/{id} is safe (already deleted = no-op)
For the unsafe cases, your integration must provide idempotency at the partner-side state level:
JavaScript
The pattern: persist intent before the API call; check intent before re-trying. If the call succeeded but the response was lost (network failure), the next attempt sees the persisted intent and skips.

When this isn’t enough

For operations that can fail between “API succeeded” and “we recorded the result,” you have a brief window where:
  • The API created the Group
  • The network response was lost
  • The retry creates a new Group
Without idempotency keys on the API, this race is fundamental. Mitigations:
  • After failures, search for the just-created resource before retrying. For Groups, list recently-created Groups; if one matches your intent, use it instead of creating a new one
  • For non-Group resources (Users via upsert, Project via PUT), the upsert/PUT semantics naturally idempotent — the worst case is “second attempt finds it already in the desired state”
For VOMO, this is mostly an issue with Group creates. Most other operations are naturally idempotent.

Principle 7: graceful degradation

When some part of the integration is broken, what continues working? Design for partial functionality:

Tiered functionality

JavaScript
Using Promise.allSettled (vs. Promise.all) means one failed fetch doesn’t fail the whole dashboard. The UI shows “data temporarily unavailable” for the broken section, but the rest is visible.

Cache fallback

JavaScript
When the fresh fetch fails, fall back to cached data — even if it’s expired. Mark it as stale so UI can show “data is from N minutes ago.” This is essential for customer-facing UIs where “the page is completely broken” is worse than “the data is slightly out of date.”

Principle 8: comprehensive error logging

When something breaks, the team needs to debug it. Structured error logging is the foundation:
JavaScript
The structured log includes:
  • The trace ID (correlates across requests)
  • The specific record being processed
  • The full error including HTTP context (status, body)
  • The error class for grouping
In an aggregator (DataDog, ELK, etc.), you can filter for “all failures with status 422 for customer X” and surface patterns.

Principle 9: alert on patterns, not individual failures

A single failure isn’t alarming. A pattern of failures is. Alert thresholds should reflect this: Build alerts on patterns. Suppress noise from one-off transients. The team should only be paged when intervention is genuinely needed.

Principle 10: error budget thinking

For long-running integrations, embrace the idea of an error budget: a defined “acceptable” error rate, below which alerts don’t fire.
JavaScript
The pattern accepts that some level of failure is normal while triggering action when failure rates exceed expectations.

A reference resilient integration

The patterns combined:
JavaScript
The patterns layered: retry → circuit breaker → DLQ → alerting. Each layer handles failures the previous can’t, and together they produce an integration that runs continuously through transient and persistent issues alike.

Where to go next

Sync Architecture Patterns

The broader architectural patterns these recovery practices fit into.

API Performance Tips

The performance patterns these resilience patterns coexist with.

Data Modeling

The data model that supports the DLQ and audit log patterns.

Change Detection Best Practices

The change-detection reliability practices this builds on.
Last modified on May 22, 2026