---
title: "Async error handling and a retry design that leaves evidence"
url: "https://trailassembly.com/apex-async-retry-pattern/"
topic: "Apex and Development"
published: "2026-09-12T03:44:43+00:00"
updated: "2026-10-06T13:53:16+00:00"
error_codes: "CalloutException"
---

# Async error handling and a retry design that leaves evidence

**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:

```apex
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](https://trailassembly.com/callout-read-timed-out/).

## 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:

```apex
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:

```apex
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](https://trailassembly.com/apex-choosing-async/) 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:

```apex
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](https://trailassembly.com/upsert-with-external-ids/) 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](https://trailassembly.com/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.

---

Source: https://trailassembly.com/apex-async-retry-pattern/
Trail Assembly — independent Salesforce write-ups.
