Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

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.

A retry that does not become a silent failureSomewhere durable to record the attempt, a decision about what is worth retrying, and a ceiling.01Write the unit of work to a custom object, in its own transaction, beforeyou attempt it02Classify the failure: timeouts and 502/503 retry; 400, 401 and 422 neverdo03Back off exponentially, and add random jitter so failures do not allreturn at once04Ceiling at five attempts, then mark Abandoned and stop05A scheduled batch picks up what is due, rather than a job chaining toitself06None of it is safe unless the work is idempotent — send a caller-generatedkeyA rolled-back transaction takes your failure record with it. Create the task before the work,not alongside it, or the evidence disappears with the failure.
A retry loop that leaves evidence, instead of one that fails quietly forever.

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.