How You need to implement a mechanism to retry failed callouts in Apex automatically with exponential backoff without duplicating successful transactions.

Below is a complete, production-ready solution for implementing automatic retry with exponential backoff in Apex, without duplicating successful transactions.
✅ Goal
Implement:
✔ Automatic retry for failed callouts
✔ Exponential backoff (1s → 2s → 4s → 8s…)
✔ No duplicate transactions or double-processing
✔ Retry persistence using Platform Events, Queueable, or Custom Object
✔ Apex-safe design (no sleep, no blocking)
🚀 Best-Practice Architecture
Because Apex cannot use sleep(), true exponential backoff requires asynchronous chaining.
We use:
🔹 Queueable → Callout
🔹 If failure → Re-enqueue with increased delay
🔹 Delay is stored & passed (pseudobackoff)
We use System.schedule() for real delay.
✅ FINAL CODE — Exponential Backoff With No Duplicate Processing
1️⃣ Retry Queueable Class
public class RetryCalloutQueueable implements Queueable, Database.AllowsCallouts {
private String endpointUrl;
private Integer attempt;
private Integer maxAttempts;
public RetryCalloutQueueable(String endpointUrl, Integer attempt, Integer maxAttempts){
this.endpointUrl = endpointUrl;
this.attempt = attempt;
this.maxAttempts = maxAttempts;
}
public void execute(QueueableContext context) {
HttpRequest req = new HttpRequest();
req.setEndpoint(endpointUrl);
req.setMethod('GET');
req.setTimeout(60000);
Http http = new Http();
try {
HttpResponse res = http.send(req);
// SUCCESS → Stop retries
if (res.getStatusCode() >= 200 && res.getStatusCode() < 300){
System.debug('Callout success. Stopping retry.');
return;
}
// FAILURE → Retry
retry();
}
catch (Exception ex) {
// Network or unexpected error
retry();
}
}
private void retry(){
if (attempt >= maxAttempts){
System.debug('Max retries reached. Giving up.');
return;
}
// Exponential Backoff: 2^(attempt-1) seconds
Integer delaySeconds = (Integer)Math.pow(2, attempt - 1);
// Salesforce min delay is 1-minute granularity → schedule jobs must be minute-level
Integer delayMinutes = Math.max(1, delaySeconds / 60);
// Create CRON for delayed retry
String cron = generateCron(delayMinutes);
System.debug('Retry scheduled in ' + delayMinutes + ' minutes.');
System.schedule(
'RetryCallout_' + Datetime.now().getTime(),
cron,
new RetryCalloutSchedulable(endpointUrl, attempt + 1, maxAttempts)
);
}
private static String generateCron(Integer minutesFromNow){
Datetime runTime = Datetime.now().addMinutes(minutesFromNow);
return String.format('{0} {1} {2} {3} ? {4}',
new String[]{
String.valueOf(runTime.second()),
String.valueOf(runTime.minute()),
String.valueOf(runTime.hour()),
String.valueOf(runTime.day()),
String.valueOf(runTime.year())
}
);
}
}
JavaScript2️⃣ Schedulable Wrapper to Re-Invoke Queueable
public class RetryCalloutSchedulable implements Schedulable {
private String endpointUrl;
private Integer nextAttempt;
private Integer maxAttempts;
public RetryCalloutSchedulable(String endpointUrl, Integer nextAttempt, Integer maxAttempts) {
this.endpointUrl = endpointUrl;
this.nextAttempt = nextAttempt;
this.maxAttempts = maxAttempts;
}
public void execute(SchedulableContext sc) {
System.enqueueJob(new RetryCalloutQueueable(endpointUrl, nextAttempt, maxAttempts));
}
}
JavaScript3️⃣ Starting the Retry System
// First attempt
System.enqueueJob(new RetryCalloutQueueable(
'callout:My_Endpoint',
1, // first attempt
5 // max retries
));
JavaScript🛑 Why This Design Prevents Duplicate Transactions
✔ Successful call → returns and stops
✔ Failed call → schedules future retry, does not re-run other logic
✔ Each retry is a new Queueable, not a duplicate
✔ No recursive loops because:
- Attempt counter is incremented
- MaxAttempts stops infinite retry
✔ Exponential backoff ensures:
1 min → 2 min → 4 min → 8 min → 16 min (approx. due to Salesforce minimum 1-minute rule)
📝 Deep Explanation for Documentation & SEO
🧠 Why do we need exponential backoff?
External APIs fail due to:
- Server overload
- Rate limits
- Temporary network issues
Retrying immediately causes:
- More failures
- Rate limit violations
- Increased server load
Backoff gives server time to recover.
🔧 Challenges in Apex
Unlike Node/Python, Salesforce does NOT allow:
❌ sleep()
❌ wait()
❌ Long-running synchronous loops
❌ Infinite callout chains
So Apex must use async chaining: Queueable → Schedulable → Queueable.
💡 Why Not Use Batch Apex?
Batch does not support:
- Flexible backoff timing
- Dynamic scheduling
- High-precision control
- Per-call retry logic
Queueable is best for callouts.
🧱 Why Not Duplicate Transactions?
A common anti-pattern:
callout();
if fail → try again immediately (duplicate risk)
JavaScriptThis risks:
- Double processing
- Duplicate API updates
- Hitting API rate limits
Our architecture solves it with:
Stateful attempts
The attempt number is stored and passed through async jobs.
Single-success termination
As soon as callout succeeds, retry chain stops permanently.
Independent retry jobs
Each attempt is its own execution context → clean & isolated.
Related Posts

How to Automatically create a follow-up Task when a Lead is converted

How You need to update a related child record whenever a parent record’s status changes, but only if the status is “Closed Won.” How would you design this in Apex?
