SfSkills Domains Docs GitHub

Home / apex / apex-callout-retry-and-resilience

apex-callout-retry-and-resilience

Strategy layer for resilient Apex HTTP callouts: bounded retry with backoff, queueable async retry chains, circuit-breaker via Platform Cache, idempotency keys, dead-letter pattern. NOT for callout authentication — use apex/apex-named-credentials-patterns. NOT for callout/DML transaction-boundary rules — use apex/callout-and-dml-transaction-boundaries.

Open the skill folder on GitHub


Apex Callout Retry and Resilience

Activate when an Apex integration must survive transient failures from a downstream system: 5xx errors, network timeouts, 429 rate limits, brief endpoint outages. This skill is the strategy layer — it tells you when to retry, how many times, on what schedule, when to stop, and where to park failures. The HTTP plumbing itself lives in templates/apex/HttpClient.cls.

Before Starting

Core Concepts

Retry classification

Status / FailureRetry?Why
Network timeout, no responseYesTransient
408 Request TimeoutYesTransient
429 Too Many RequestsYes (honor Retry-After)Rate limit
500, 502, 503, 504YesServer-side transient
400, 422NoCaller payload bug
401, 403NoAuth / authz; refresh token elsewhere
404NoResource doesn't exist

The Apex synchronous constraint

A synchronous Apex transaction has a 120-second cumulative callout cap and no usable Thread.sleep (Apex offers no sleep primitive — busy-wait loops via System.now() polling are governor-killers and forbidden). This forces synchronous retries to be:

For real exponential backoff (seconds-to-minutes), go async.

Async retry — Queueable chain or Platform Event

The right pattern for backoff > a few seconds:

  1. First attempt fires sync (or from a Queueable).
  2. On retryable failure, the Queueable enqueues itself with attempt + 1 and a stored nextAttemptAt.
  3. A Scheduled Apex job (or Platform Event subscriber) picks up the work when nextAttemptAt <= now.

Each retry runs in a fresh transaction with fresh governor limits. Backoff schedules of 1s / 5s / 30s / 5min are achievable.

Circuit breaker

Track per-endpoint failure rate in Cache.Org (org-partition Platform Cache). Three states:

The cache key MUST be per-endpoint (e.g. ckt:payment-api), not global. One bad endpoint shouldn't blackhole every integration.

Idempotency

For retryable writes, the downstream must dedup. Two patterns:

Dead-letter pattern

When retries exhaust (e.g. 5 attempts over 1 hour), write the failed payload to Failed_Callout__c with: endpoint, payload, last response, attempt count, last error. A scheduled job or admin UI can reprocess. Without this, retries that exhaust silently disappear.

Governor budget reminder

Testing

MockHttpResponseGenerator can return a sequence of responses by storing call-count state in the mock class. Test patterns: 3 timeouts then success; 5 5xx triggering circuit-open; 4xx never retried.

  1. Classify the integration: idempotent vs non-idempotent; sync vs async tolerable; latency budget.
  2. Pick the retry policy: max attempts, backoff schedule, jitter (10-20% randomization to avoid thundering herd).
  3. Decide circuit-breaker thresholds per endpoint and store config in Custom Metadata (Callout_Resilience_Config__mdt).
  4. Implement Idempotency-Key generation (UUID per logical request, persisted on the source record so retries reuse it).
  5. Design the dead-letter sObject and the reprocessing path (scheduled Apex + admin reprocess UI).
  6. Write MockHttpResponseGenerator returning a sequence — assert retry count, circuit-open behavior, and dead-letter writes.
  7. Deploy with circuit-breaker thresholds tuned conservatively; monitor Failed_Callout__c volume in week 1.

Review Checklist

Salesforce-Specific Gotchas

  1. 120-second cumulative callout cap is HARD. Three retries with 30s timeouts each plus backoff burns the budget fast. Measure actual P95 latency before sizing retries.
  2. Platform Cache keys are case-sensitive. ckt:Payment-Api and ckt:payment-api are different breakers — pick a convention and lint it.
  3. Limits.getCallouts() is per-transaction, not per-class. A trigger that fires three handlers each issuing 40 callouts will hit 100 even though no single class did.
  4. Test.setMock returns a single mock instance. To return a sequence, store call-count state inside the mock class and switch on it.

Output Artifacts

ArtifactDescription
Retry policy specAttempts, backoff, jitter, eligible status codes
Circuit-breaker configThreshold, window, cooldown, partition key — per endpoint
Idempotency strategyHeader-based or dedup-table; key persistence rule
Failed_Callout__c schemaDead-letter object + reprocessing job
Test classMockHttpResponseGenerator with response-sequence support

Package files