The quick version: retries need somewhere durable to record the attempt, a decision about which failures are worth retrying, and a ceiling. A try/catch that re-enqueues itself has none of those, and it is how an org ends up with a job that has failed 14,000 times since March and nobody knows.
Write the attempt down
The debug log expires. AsyncApexJob keeps a status and not your payload. So the first piece is a custom object — one record per unit of work, carrying what you were trying to do:
Integration_Task__c
Payload__c Long Text
Status__c Pending / In Progress / Failed / Succeeded / Abandoned
Attempts__c Number
Next_Attempt__c DateTime
Last_Error__c Long Text
External_Key__c Text, External Id, Unique
Now a failure is a record someone can report on, and a retry is a query rather than a chain of jobs holding state in memory. The external key is what makes the whole thing idempotent, which matters because a timeout never means nothing happened.
Decide what is retryable
Retrying everything is as wrong as retrying nothing. Three categories:
- Retry: timeouts, 502 and 503, row lock contention, “server busy”. Conditions that resolve on their own.
- Do not retry: 400, 401, 403, 422, validation failures. A bad request stays bad, and retrying it on a timer just fails on a schedule.
- Retry once, then stop: 500. It might be transient and it might be a bug in their code, and one more attempt distinguishes them cheaply.
Make the classification a method, not a chain of string comparisons scattered through the catch block:
private static Boolean isRetryable(Integer status, Exception e) {
if (e instanceof CalloutException) { return true; }
return status == 429 || status == 502 || status == 503;
}
Back off, and add jitter
Immediate retries make an overloaded system worse. Double the delay each attempt, and add a random element so that two hundred failed tasks do not all return at the same second:
Integer delayMinutes = (Integer) Math.pow(2, task.Attempts__c)
+ Math.round(Math.random() * 5);
task.Next_Attempt__c = System.now().addMinutes(delayMinutes);
The jitter is the part people leave out, and it is the difference between a recovering system and a thundering herd that keeps knocking the far end over.
Set a ceiling and mean it
After five attempts, set the status to Abandoned and stop. A task that retries forever is not resilience, it is a silent failure with extra API calls. Abandoned tasks should appear on a report somebody actually looks at, and the useful trigger for a human is “anything abandoned in the last 24 hours”, not a running count.
The scheduler, not the chain
Chaining a queueable to itself works and couples the retry to the failing job. A scheduled job that picks up due work is more solid, because a single crash does not end the chain:
global class RetryScheduler implements Schedulable {
global void execute(SchedulableContext sc) {
Database.executeBatch(new RetryBatch(), 50);
}
}
// RetryBatch start()
return Database.getQueryLocator([
SELECT Id, Payload__c, Attempts__c FROM Integration_Task__c
WHERE Status__c = 'Pending' AND Next_Attempt__c <= :System.now()
ORDER BY Next_Attempt__c LIMIT 2000
]);
A small scope size matters here. Each batch chunk gets its own callout allowance, so 50 tasks per chunk stays well inside the 100-callout limit with room for a token refresh.
Make the work idempotent first
None of this is safe unless a repeated attempt is harmless. Send a caller-generated key with every request and have the far end treat a repeat as the same operation, or upsert on an external ID on the way back in. Without that, your retry logic is a duplicate-creation machine with good intentions, and the details are in callout retries and idempotency.
Frequently asked
Why not just catch and re-enqueue? Queueable chaining has a depth limit, loses the payload if the transaction rolls back, and leaves no record when it finally gives up.
Does a rolled-back transaction keep my task record? No. Create the task in its own transaction before the work, not alongside it, or the record documenting the failure disappears with the failure.
How do I stop a runaway? A kill switch in custom metadata that the batch checks first. Cheaper than aborting jobs under pressure.
