Your Apex Code Will Fail. Here’s How to Make It Try Again.
A simple, reusable retry framework for Salesforce, explained so every developer can use it.
It was 4:47 on a Friday when a sales ops manager pinged me. “About 300 leads didn’t convert last night. But the job says it ran fine.”
She was right. It had.
Two processes had tried to update the same Account at the same moment, and one of them got locked out. An old try/catch wrote the error to a debug log and carried on as if nothing happened. No retry. No alert. Fixing it took forty minutes. Finding it took a day and a half.
After that weekend I built one small retry framework, and I’ve reused it ever since. Here’s how it works.
Four words before we start
- Transient error: a temporary problem. Try again and it usually works, like a record locked by someone else.
- Permanent error: trying again won’t help, like a missing required field.
- Queueable: Apex that runs in the background, after the user’s click is done.
- Finalizer: code Salesforce runs after a Queueable finishes, even if the Queueable crashed.
Step 1: Only retry what’s worth retrying
Most retry code retries everything. But a missing field fails the same way on the third try, so you’ve only burned limits and buried the real bug.
So I keep that decision in one method, and the whole org uses it:
public static Boolean isTransient(Exception error) {
// Another process had the record locked
if (error instanceof DmlException) {
DmlException dmlError = (DmlException) error;
return dmlError.getDmlType(0)
== StatusCode.UNABLE_TO_LOCK_ROW;
}
// The external system was too slow to answer
if (error instanceof CalloutException) {
return error.getMessage().contains('timed out');
}
// Bad data or a code bug: retrying won't fix it
return false;
}Step 2: Describe the work, not the retry
Anything you want retried implements one small interface. You write what should happen. The framework works out when to try again.
public interface RetryableWork {
void execute(RetryContext ctx); // do the work once
String key(); // a unique name for it
Type workType(); // lets it be replayed
}Here’s a complete example. It sends an order to an ERP system:
public class SendOrderToErp implements RetryableWork {
public Id orderId;
public SendOrderToErp() {} // needed for replay
public SendOrderToErp(Id orderId) {
this.orderId = orderId;
}
public void execute(RetryContext ctx) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:ERP_API/orders/' + orderId);
req.setMethod('POST');
HttpResponse res = new Http().send(req);
if (res.getStatusCode() == 503) { // server busy
ctx.retry('ERP is busy'); // try again later
}
}
public String key() { return 'ERP-ORDER-' + orderId; }
public Type workType() { return SendOrderToErp.class; }
}Notice that ctx.retry() asks for another attempt without throwing an error. An unhandled error rolls back everything the transaction saved, so this keeps the work that already succeeded.
Step 3: Pick how it runs
It’s one line, and you have three choices:
// Try now, up to 3 times. Still failing? Go to background.
Retry.now(work, 'RowLock');
// Run in the background with automatic retries
Retry.async(new SendOrderToErp(order.Id), 'ERP');
// Run later, after a vendor's maintenance window
Retry.at(work, 'ERP', Datetime.now().addHours(4));'RowLock' and 'ERP' are retry policies. A policy sets how many attempts are allowed and how long to wait between them. Policies live in Custom Metadata, so an admin can tune them without a deployment.
Step 4: Let the Finalizer decide what’s next
Your work runs in a Queueable. When it ends, the Finalizer checks what happened and picks the next move. Simplified:
public void execute(FinalizerContext ctx) {
// It worked. Nothing to do.
if (ctx.getResult() == ParentJobResult.SUCCESS) {
return;
}
Exception error = ctx.getException();
// Temporary problem with tries left: wait, then retry
if (RetryClassifier.isTransient(error)
&& job.hasTriesLeft()) {
System.enqueueJob(job.nextAttempt(), job.waitMinutes());
return;
}
// Out of luck: save it so a person can replay it later
RetryStore.deadLetter(job, error);
}Each wait is about double the last one: 1 minute, then 2, then 4. Work that runs out of tries is saved as a “dead letter” record. Once someone fixes the cause, they click Replay and it runs again.
Get Bharath Mulpuri’s stories in your inbox
Join Medium for free to get updates from this writer.
I learned these three rules the hard way:
- Attach the Finalizer on the first line of your Queueable. If anything fails before that line, nothing gets retried.
- Keep the attempt count on the job, not in a
staticvariable. Statics reset on every attempt, so "three tries" becomes forever. - Never catch an error and ignore it. The Finalizer will think everything went fine.
It covers the scenarios you actually hit
- A save runs into a locked record:
Retry.now()tries again right away, then moves the work to the background. - An external system is slow or down: waits get longer between tries, and calls pause completely while the system is down.
- A Platform Event fails: it gets a few retries, then it’s set aside so one bad message can’t block the rest.
- A Batch Apex chunk fails: only that chunk runs again.
- A record you need doesn’t exist yet: throw
Retry.TransientExceptionand it's retried later.
Before you add a retry, ask one question
What happens if this runs twice?
Sometimes the first attempt worked and only the response got lost, so a retry creates a duplicate. Use upsert with an External ID instead of insert, and send a unique key with every callout.
Your code will fail. Row locks happen and APIs go down. What matters is whether it fails forward, into another attempt or into a record someone can replay in minutes.
That Friday would have been a quiet one.
If this helped, a clap helps other Salesforce developers find it. I’d love to hear your own retry horror story in the comments.








