Handle Declines and Retries
By the end of this lesson, you'll be able to:
- Distinguish a decline (no retry) from an error (retry) in the Queueable job
- Implement a simple retry with a maximum attempt count
- Explain why retrying a decline would be a business logic mistake
Prerequisites: "Make It Asynchronous (Queueable)"
Why a decline should never be retried
Retrying a definitively declined charge — the same card, the same amount, immediately again — accomplishes nothing except annoying the customer and potentially triggering fraud detection on the gateway's side. This is exactly why Lesson 1 defined declined as a distinct, non-retryable outcome from error in the first place.
Retrying an error, with a limit
public class ProcessPaymentJob implements Queueable {
private Id orderId;
private Decimal amount;
private String paymentToken;
private Integer attemptNumber;
public ProcessPaymentJob(Id orderId, Decimal amount, String paymentToken, Integer attemptNumber) {
this.orderId = orderId;
this.amount = amount;
this.paymentToken = paymentToken;
this.attemptNumber = attemptNumber;
}
public void execute(QueueableContext context) {
PaymentResult result = new PaymentGatewayService().charge(amount, paymentToken);
Order__c order = new Order__c(Id = orderId);
if (result.success) {
order.Status__c = 'Paid';
order.Payment_Reference__c = result.gatewayTransactionId;
update order;
} else if (result.declined) {
order.Status__c = 'Payment Declined';
order.Decline_Reason__c = result.message;
update order;
} else if (attemptNumber < 3) {
System.enqueueJob(new ProcessPaymentJob(orderId, amount, paymentToken, attemptNumber + 1));
} else {
order.Status__c = 'Payment Error';
update order;
}
}
}
public class ProcessPaymentJob implements Queueable {— the same class as Lesson 3.private Id orderId;— unchanged.private Decimal amount;— unchanged.private String paymentToken;— unchanged.private Integer attemptNumber;— a new field, tracking how many times this charge has been attempted.- (blank line)
public ProcessPaymentJob(Id orderId, Decimal amount, String paymentToken, Integer attemptNumber) {— the constructor, extended with the new parameter.this.orderId = orderId;— unchanged.this.amount = amount;— unchanged.this.paymentToken = paymentToken;— unchanged.this.attemptNumber = attemptNumber;— stores the new field.}— closes the constructor.- (blank line)
public void execute(QueueableContext context) {— runs when the job fires.PaymentResult result = new PaymentGatewayService().charge(amount, paymentToken);— the callout, unchanged.- (blank line)
Order__c order = new Order__c(Id = orderId);— unchanged.if (result.success) {— the success branch.order.Status__c = 'Paid';— unchanged.order.Payment_Reference__c = result.gatewayTransactionId;— unchanged.update order;— a final outcome — saved immediately.} else if (result.declined) {— the decline branch.order.Status__c = 'Payment Declined';— unchanged.order.Decline_Reason__c = result.message;— unchanged.update order;— also final — never retried.} else if (attemptNumber < 3) {— the new branch — an error, but under the retry limit.System.enqueueJob(new ProcessPaymentJob(orderId, amount, paymentToken, attemptNumber + 1));— Module 38's job-chaining, incrementing the attempt count.} else {— an error, and the limit has been reached.order.Status__c = 'Payment Error';— gives up and records the final status.update order;— saves it.}— closes the four-way branch.}— closesexecute.}— closes the class.
attemptNumber (Module 9's parameter passing, extended with one more field) tracks how many times this specific charge has been attempted — an error triggers Module 38's job-chaining pattern to retry, but only up to 3 attempts, after which it gives up and records the final error status.
Updating the trigger handler for the new constructor
if (justReadyForPayment) {
System.enqueueJob(new ProcessPaymentJob(order.Id, order.Total__c, order.Payment_Token__c, 1));
}
if (justReadyForPayment) {— unchanged from Lesson 3.System.enqueueJob(new ProcessPaymentJob(order.Id, order.Total__c, order.Payment_Token__c, 1));— now passes1as the startingattemptNumber— the first attempt.}— closes theif.
The trigger's original enqueue call now passes 1 as the starting attemptNumber — the first attempt — with ProcessPaymentJob itself handling every retry afterward via chaining, never requiring the original trigger to know or care about retries at all.
Exercise
As a comment, explain what would go wrong if the else if (attemptNumber < 3) branch also ran for a decline, not just an error.
Show hint
Think about what retrying an already-declined charge would actually mean for a customer.
Handle Declines and Retries Quiz
My Notes
Log in to keep private notes on this lesson.
Questions about this lesson
No questions yet — be the first to ask.
Log in to ask a question about this lesson.
Summary
This lesson implements Lesson 1's success/decline/error distinction fully — specifically, automatically retrying an error (but never a decline), with a maximum attempt count to avoid retrying forever.