├── config
├── project-scratch-def.json
└── retry-org-scratch-def.json
├── force-app
└── main
│ └── default
│ ├── classes
│ ├── UtilsTest.cls
│ ├── Logger.cls
│ ├── Utils.cls
│ ├── JobResult.cls
│ ├── HTTPMockFactory.cls
│ ├── RetryScheduler.cls
│ ├── RetryableMockCallout.cls
│ ├── RetryableMock.cls
│ ├── BatchableRetry.cls
│ ├── Retryable.cls
│ ├── RetryableTest.cls
│ └── BatchableRetryTest.cls
│ └── objects
│ └── RetryableJob__c
│ ├── fields
│ ├── lastTry__c.field-meta.xml
│ ├── nextTry__c.field-meta.xml
│ ├── firstTry__c.field-meta.xml
│ ├── serializedJob__c.field-meta.xml
│ ├── message__c.field-meta.xml
│ ├── status__c.field-meta.xml
│ ├── className__c.field-meta.xml
│ └── count__c.field-meta.xml
│ └── RetryableJob__c.object-meta.xml
├── sfdx-project.json
├── LICENSE
└── README.md
/config/project-scratch-def.json:
--------------------------------------------------------------------------------
1 | {
2 | "orgName": "jantaks Company",
3 | "edition": "Developer",
4 | "orgPreferences" : {
5 | "enabled": ["S1DesktopEnabled"]
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/config/retry-org-scratch-def.json:
--------------------------------------------------------------------------------
1 | {
2 | "orgName": "retry-org",
3 | "edition": "Enterprise",
4 | "features": [],
5 | "orgPreferences": {
6 | "enabled": [],
7 | "disabled": []
8 | }
9 | }
--------------------------------------------------------------------------------
/force-app/main/default/classes/UtilsTest.cls:
--------------------------------------------------------------------------------
1 | @IsTest
2 | private class UtilsTest {
3 | @IsTest
4 | static void testCronFromDateTime() {
5 | Datetime dt = Datetime.newInstance(2019, 8,19,10,55,3);
6 | String result = Utils.toCronExpression(dt);
7 | System.assert(result == '3 55 10 19 8 ? 2019');
8 | }
9 | }
--------------------------------------------------------------------------------
/force-app/main/default/classes/Logger.cls:
--------------------------------------------------------------------------------
1 | public with sharing class Logger {
2 | private String loggingObject;
3 |
4 | public Logger(Object loggingObject){
5 | this.loggingObject = String.valueOf(loggingObject).split(':')[0];
6 | }
7 |
8 | public void d(String message) {
9 | System.debug(String.valueOf(loggingObject).split(':')[0] + ': ' + message);
10 | }
11 | }
--------------------------------------------------------------------------------
/force-app/main/default/classes/Utils.cls:
--------------------------------------------------------------------------------
1 | public with sharing class Utils {
2 |
3 | public static String toCronExpression(Datetime d){
4 | return d.second() + ' ' +
5 | d.minute() + ' ' +
6 | d.hour() + ' ' +
7 | d.day() + ' ' +
8 | d.month() + ' ' +
9 | '?' + ' ' +
10 | d.year();
11 | }
12 | }
--------------------------------------------------------------------------------
/force-app/main/default/objects/RetryableJob__c/fields/lastTry__c.field-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | lastTry__c
4 | false
5 |
6 | false
7 | false
8 | DateTime
9 |
10 |
--------------------------------------------------------------------------------
/force-app/main/default/objects/RetryableJob__c/fields/nextTry__c.field-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | nextTry__c
4 | false
5 |
6 | false
7 | false
8 | DateTime
9 |
10 |
--------------------------------------------------------------------------------
/force-app/main/default/objects/RetryableJob__c/fields/firstTry__c.field-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | firstTry__c
4 | false
5 |
6 | false
7 | false
8 | DateTime
9 |
10 |
--------------------------------------------------------------------------------
/force-app/main/default/objects/RetryableJob__c/fields/serializedJob__c.field-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | serializedJob__c
4 | false
5 |
6 | 32768
7 | false
8 | LongTextArea
9 | 3
10 |
11 |
--------------------------------------------------------------------------------
/force-app/main/default/objects/RetryableJob__c/fields/message__c.field-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | message__c
4 | false
5 |
6 | 255
7 | false
8 | false
9 | Text
10 | false
11 |
12 |
--------------------------------------------------------------------------------
/force-app/main/default/objects/RetryableJob__c/fields/status__c.field-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | status__c
4 | false
5 |
6 | 50
7 | false
8 | false
9 | Text
10 | false
11 |
12 |
--------------------------------------------------------------------------------
/force-app/main/default/objects/RetryableJob__c/fields/className__c.field-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | className__c
4 | false
5 |
6 | 50
7 | false
8 | false
9 | Text
10 | false
11 |
12 |
--------------------------------------------------------------------------------
/sfdx-project.json:
--------------------------------------------------------------------------------
1 | {
2 | "packageDirectories": [
3 | {
4 | "path": "force-app",
5 | "package": "retry-framework",
6 | "versionName": "ver 0.1",
7 | "versionNumber": "0.1.0.NEXT",
8 | "default": true,
9 | "dependencies": []
10 | }
11 | ],
12 | "namespace": "",
13 | "sfdcLoginUrl": "https://login.salesforce.com",
14 | "sourceApiVersion": "45.0",
15 | "packageAliases": {
16 | }
17 | }
--------------------------------------------------------------------------------
/force-app/main/default/objects/RetryableJob__c/fields/count__c.field-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | count__c
4 | false
5 |
6 | 3
7 | false
8 | 0
9 | false
10 | Number
11 | false
12 |
13 |
--------------------------------------------------------------------------------
/force-app/main/default/classes/JobResult.cls:
--------------------------------------------------------------------------------
1 | public class JobResult{
2 |
3 | public Retryable.Status status {get; set;}
4 | public String message {get; set;}
5 |
6 | public JobResult(Retryable.Status status, String message){
7 | this.status = status;
8 | this.message = message;
9 | }
10 |
11 | public static JobResult retry(String message){
12 | return new JobResult(Retryable.Status.FAILED_RETRY, message.abbreviate(255));
13 | }
14 |
15 | public static JobResult success(String message){
16 | return new JobResult(Retryable.Status.SUCCEEDED, message.abbreviate(255));
17 | }
18 |
19 | public static JobResult maximumRetries(String message){
20 | return new JobResult(Retryable.Status.MAX_RETRIES, message.abbreviate(255));
21 | }
22 |
23 | public static JobResult actionRequired(String message){
24 | return new JobResult(Retryable.Status.FAILED_ACTION_REQUIRED, message.abbreviate(255));
25 | }
26 |
27 |
28 | }
--------------------------------------------------------------------------------
/force-app/main/default/classes/HTTPMockFactory.cls:
--------------------------------------------------------------------------------
1 | @isTest
2 | public class HTTPMockFactory implements HttpCalloutMock {
3 | public Integer responseCode {get; set;}
4 | public String status {get; set;}
5 | public String response {get; set;}
6 | protected Map responseHeaders;
7 | public HttpRequest request {get; set;}
8 |
9 | public HTTPMockFactory(Integer responseCode, String status, String response, Map responseHeaders) {
10 | this.responseCode = responseCode;
11 | this.status = status;
12 | this.response = response;
13 | this.responseHeaders = responseHeaders;
14 | }
15 | public HTTPResponse respond(HTTPRequest req) {
16 | this.request = req;
17 | HttpResponse res = new HttpResponse();
18 | for (String key : this.responseHeaders.keySet()) {
19 | res.setHeader(key, this.responseHeaders.get(key));
20 | }
21 | res.setBody(this.response);
22 | res.setStatusCode(this.responseCode);
23 | res.setStatus(this.status);
24 | return res;
25 | }
26 | }
--------------------------------------------------------------------------------
/force-app/main/default/classes/RetryScheduler.cls:
--------------------------------------------------------------------------------
1 | public with sharing class RetryScheduler {
2 |
3 | private static final String JOB_PREFIX = 'RetryBatch@';
4 | private static final Integer MAX_CALLOUTS_IN_SINGLE_TRANSACTION = 100;
5 | private static final Integer MINUTES_FROM_NOW = 5;
6 | private static final Logger LOG = new Logger(RetryScheduler.class.getName());
7 |
8 | public static String schedule(Integer minutesFromNow) {
9 | List cronTriggers = [
10 | SELECT Id, NextFireTime, CronJobDetail.Name
11 | FROM CronTrigger
12 | WHERE CronJobDetail.Name LIKE :'%' + JOB_PREFIX + '%' AND NextFireTime != NULL
13 | ];
14 | return cronTriggers.isEmpty()
15 | ? scheduleNewBatch(minutesFromNow)
16 | : cronTriggers[0].Id;
17 | }
18 |
19 | private static String scheduleNewBatch(Integer minutesFromNow) {
20 | LOG.d('SCHEDULING NEW JOB');
21 | BatchableRetry batchJob = new BatchableRetry();
22 | String jobDescription = JOB_PREFIX + System.now();
23 | return System.scheduleBatch(batchJob, jobDescription, minutesFromNow, MAX_CALLOUTS_IN_SINGLE_TRANSACTION);
24 | }
25 |
26 | public static String schedule() {
27 | return schedule(MINUTES_FROM_NOW);
28 | }
29 | }
--------------------------------------------------------------------------------
/force-app/main/default/classes/RetryableMockCallout.cls:
--------------------------------------------------------------------------------
1 | public with sharing class RetryableMockCallout extends Retryable {
2 | private Logger log = new Logger(RetryableMockCallout.class.getName());
3 |
4 | public RetryableMockCallout() {
5 | firstExecution = Datetime.newInstance(2019, 1, 1, 8, 0, 0);
6 | retryScheduleInMinutes = new List{
7 | 5, 10, 30
8 | };
9 | }
10 |
11 | public override JobResult startJob() {
12 | log.d('Started MockCallout');
13 | Http http = new Http();
14 | HttpRequest request = new HttpRequest();
15 | request.setEndpoint('callout:YOUR_SERVICE_ENDPOINT');
16 | request.setMethod('POST');
17 | request.setHeader('Content-Type', 'application/json');
18 | request.setHeader('Accept', 'application/json');
19 | request.setBody('{"Content": "Your Content"}');
20 | HttpResponse response = http.send(request);
21 | Integer httpResponseCode = response.getStatusCode();
22 | switch on httpResponseCode{
23 | when 200,201{
24 | return JobResult.success(response.getBody());
25 | }
26 | when 401{
27 | return JobResult.actionRequired(response.getBody());
28 | }
29 | when else {
30 | return JobResult.retry(response.getBody());
31 | }
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2020, Jan Taks
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/force-app/main/default/classes/RetryableMock.cls:
--------------------------------------------------------------------------------
1 | public virtual class RetryableMock extends Retryable {
2 | private Logger log = new Logger(RetryableMock.class.getName());
3 | private final Boolean succeeds;
4 | private final String jobName;
5 | private final Retryable.Status resultStatus;
6 |
7 | static Integer jobCount = 0;
8 |
9 | public static RetryableMock newInstance(Boolean succeeds) {
10 | jobCount++;
11 | return new RetryableMock(succeeds, 'Job ' + jobCount);
12 | }
13 |
14 | public static RetryableMock newInstance(Retryable.Status expectedStatus) {
15 | jobCount++;
16 | return new RetryableMock(expectedStatus, 'Job ' + jobCount);
17 | }
18 |
19 | protected RetryableMock(Retryable.Status status, String jobName) {
20 | this.jobName = jobName;
21 | this.resultStatus = status;
22 | firstExecution = Datetime.newInstance(2019, 1, 1, 8, 0, 0);
23 | retryScheduleInMinutes = new List{5,10,30};
24 | }
25 |
26 | protected RetryableMock(Boolean succeeds, String jobName) {
27 | this.succeeds = succeeds;
28 | this.jobName = jobName;
29 | firstExecution = Datetime.newInstance(2019, 1, 1, 8, 0, 0);
30 | retryScheduleInMinutes = new List{5,10,30};
31 | }
32 |
33 | protected virtual override JobResult startJob() {
34 | log.d(jobName + ' STARTED!');
35 | if (resultStatus != null){
36 | return new JobResult(resultStatus, 'Mock result');
37 | }
38 | return succeeds
39 | ? JobResult.success('Mock Job Success')
40 | : JobResult.retry('Mock Job Failed');
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/force-app/main/default/classes/BatchableRetry.cls:
--------------------------------------------------------------------------------
1 | public with sharing class BatchableRetry implements Database.Batchable, Database.AllowsCallouts {
2 |
3 | public Database.QueryLocator start(Database.BatchableContext context) {
4 | return Database.getQueryLocator([
5 | SELECT className__c, count__c, serializedJob__c, nextTry__c, firstTry__c
6 | FROM RetryableJob__c
7 | WHERE nextTry__c < :System.now()
8 | AND status__c = :Retryable.Status.FAILED_RETRY.name()
9 | ]);
10 | }
11 |
12 | public void execute(Database.BatchableContext context, List jobs) {
13 | List jobsToUpdate = new List();
14 | for (Sobject job : jobs) {
15 | RetryableJob__c storedJob = (RetryableJob__c) job;
16 | Type jobType = Type.forName(storedJob.className__c);
17 | Retryable currentJob = (Retryable) JSON.deserialize(storedJob.serializedJob__c, jobType);
18 | JobResult jobResult = currentJob.retry();
19 | storedJob.status__c = jobResult.status.name();
20 | storedJob.nextTry__c = jobResult.status == Retryable.Status.FAILED_RETRY? currentJob.getNextTry() : null;
21 | storedJob.lastTry__c = System.now();
22 | storedJob.serializedJob__c = JSON.serialize(currentJob);
23 | storedJob.count__c = storedJob.count__c + 1;
24 | storedJob.message__c = jobResult.message;
25 | storedJob.status__c = jobResult.status.name();
26 | jobsToUpdate.add(storedJob);
27 | }
28 | update jobsToUpdate;
29 | }
30 |
31 | public void finish(Database.BatchableContext param1) {
32 | RetryScheduler.schedule();
33 | }
34 |
35 | }
--------------------------------------------------------------------------------
/force-app/main/default/classes/Retryable.cls:
--------------------------------------------------------------------------------
1 | public abstract class Retryable implements Queueable, Database.AllowsCallouts {
2 |
3 | public enum Status {
4 | MAX_RETRIES, FAILED_RETRY, SUCCEEDED, FAILED_ACTION_REQUIRED
5 | }
6 | protected List retryScheduleInMinutes = new List{
7 | 1, 5, 10, 30, 60, 2 * 60, 4 * 60, 8 * 60, 16 * 60, 24 * 60
8 | };
9 | protected Datetime firstExecution;
10 | protected Integer retryCount = 0;
11 | private Datetime nextRetry;
12 |
13 | public void execute(QueueableContext context) {
14 | firstExecution = System.now();
15 | JobResult result = startJob();
16 | RetryScheduler.schedule();
17 | insertJob(result);
18 | }
19 |
20 | public JobResult retry() {
21 | retryCount++;
22 | nextRetry = isLastTry()
23 | ? null
24 | : firstExecution.addMinutes(retryScheduleInMinutes.get(retryCount));
25 | return isLastTry()
26 | ? JobResult.maximumRetries(startJob().message)
27 | : startJob();
28 | }
29 |
30 | protected abstract JobResult startJob();
31 |
32 | private void insertJob(JobResult result) {
33 | insert new RetryableJob__c(
34 | serializedJob__c = JSON.serialize(this),
35 | className__c = String.valueOf(this).split(':')[0],
36 | firstTry__c = System.now(),
37 | lastTry__c = System.now(),
38 | count__c = 1,
39 | nextTry__c = result.status == Retryable.Status.FAILED_RETRY
40 | ? System.now().addMinutes(retryScheduleInMinutes.get(0))
41 | : null,
42 | message__c = result.message,
43 | status__c = result.status.name()
44 | );
45 | }
46 |
47 | public Datetime getFirstExecution() {
48 | return firstExecution;
49 | }
50 |
51 | public Datetime getNextTry() {
52 | return nextRetry;
53 | }
54 |
55 | public Boolean isLastTry() {
56 | return retryCount >= retryScheduleInMinutes.size();
57 | }
58 | }
--------------------------------------------------------------------------------
/force-app/main/default/objects/RetryableJob__c/RetryableJob__c.object-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Accept
5 | Default
6 |
7 |
8 | CancelEdit
9 | Default
10 |
11 |
12 | Clone
13 | Default
14 |
15 |
16 | Delete
17 | Default
18 |
19 |
20 | Edit
21 | Default
22 |
23 |
24 | List
25 | Default
26 |
27 |
28 | New
29 | Default
30 |
31 |
32 | SaveEdit
33 | Default
34 |
35 |
36 | Tab
37 | Default
38 |
39 |
40 | View
41 | Default
42 |
43 | false
44 | SYSTEM
45 | Deployed
46 | false
47 | true
48 | false
49 | false
50 | false
51 | false
52 | false
53 | true
54 | true
55 | Private
56 |
57 |
58 |
59 | Text
60 |
61 | RetryableJobs
62 |
63 | ReadWrite
64 | Public
65 |
66 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ### Salesforce APEX Retry framework
2 |
3 | This is an easy-to-use, yet powerful framework to add retry logic to your classes. I wrote it specifically for webservice callouts but it can be used for other (distributed) jobs that have some chance of failing.
4 |
5 | A custom object provides the ability to monitor the state of your retryable jobs. For instance, you can quickly find the last result and the next scheduled retry.
6 |
7 | It is easy to implement your own retry schedule by overriding the provided base schedule. Schedules are set in minutes after the first execution. For example, if the first execution was at 8:00 and you have set your schedule to {1,5,10,30} the first retry will be close to 8:01, the second retry close to 8:05, the third close to 8:10 and so forth.
8 |
9 | The retries are selected in a batch job. The exact timing of the retries depends on how frequent the batch job is run. By default, the batch runs every 5 minutes.
10 |
11 | I would love to hear your feedback and suggestions for improvement.
12 |
13 | ### How to use
14 |
15 | Simply extend the abstract class `Retryable` and implement `protected abstract JobResult startJob();`. If the status of JobResult is `FAILED_RETRY` the framework will retry the job at the specified interval.
16 |
17 | `Retryable` implements `Queueable` so your job should be run asynchronously. For the example below this means:
18 | ```apex
19 | System.enqueueJob(new SomeCalloutRetryable('"Post":"This is my Post"'));
20 | ```
21 | The project has 100% test coverage. For more implementation details see the Test classes.
22 |
23 | ### Example implementation:
24 |
25 | ```apex
26 | public with sharing class SomeCalloutRetryable extends Retryable {
27 |
28 | private final String body;
29 |
30 | public SomeCalloutRetryable(String body) {
31 | this.body = body;
32 | retryScheduleInMinutes = new List{1, 5, 25, 60, 2*60}; //OVERRIDE DEFAULT RETRY SCHEDULE
33 | }
34 |
35 | public override JobResult startJob() {
36 | log.d('Started MockCallout');
37 | Http http = new Http();
38 | HttpRequest request = new HttpRequest();
39 | request.setEndpoint('callout:YOUR_SERVICE_ENDPOINT');
40 | request.setMethod('POST');
41 | request.setHeader('Content-Type', 'application/json');
42 | request.setHeader('Accept', 'application/json');
43 | request.setBody(this.body);
44 | HttpResponse response = http.send(request);
45 | Retryable.Status retryStatus = Utils.httpRetryScenario(response.getStatusCode(), response.getStatus());
46 | return new JobResult(retryStatus, response.getBody());
47 | }
48 | }
49 |
50 | ```
51 |
--------------------------------------------------------------------------------
/force-app/main/default/classes/RetryableTest.cls:
--------------------------------------------------------------------------------
1 | @IsTest
2 | public class RetryableTest {
3 |
4 | @IsTest
5 | static void job_fails_and_is_retryable() {
6 | RetryableMock job = RetryableMock.newInstance(false);
7 | job.execute(null);
8 | RetryableJob__c failedJob = [
9 | SELECT Id, firstTry__c, className__c, serializedJob__c, count__c, nextTry__c, message__c, status__c, lastTry__c
10 | FROM RetryableJob__c
11 | LIMIT 1
12 | ];
13 | System.assert(failedJob != null);
14 | System.assert(failedJob.firstTry__c > System.now().addMinutes(-1));
15 | System.assert(failedJob.className__c == RetryableMock.class.getName());
16 | System.assert(failedJob.serializedJob__c.contains('jobName'));
17 | System.assert(failedJob.count__c == 1);
18 | System.assert(failedJob.nextTry__c > System.now().addMinutes(4));
19 | System.assert(failedJob.message__c == 'Mock Job Failed');
20 | System.assert(failedJob.status__c == Retryable.Status.FAILED_RETRY.name());
21 | System.assert(failedJob.lastTry__c <= System.now());
22 | }
23 |
24 | @IsTest
25 | static void job_fails_and_action_is_required() {
26 | Retryable.Status expectedStatus = Retryable.Status.FAILED_ACTION_REQUIRED;
27 | RetryableMock job = RetryableMock.newInstance(expectedStatus);
28 | job.execute(null);
29 | RetryableJob__c failedJob = [
30 | SELECT Id, firstTry__c, className__c, serializedJob__c, count__c, nextTry__c, message__c, status__c, lastTry__c
31 | FROM RetryableJob__c
32 | LIMIT 1
33 | ];
34 | System.assert(failedJob != null);
35 | System.assert(failedJob.nextTry__c == null);
36 | System.assert(failedJob.status__c == expectedStatus.name());
37 | }
38 |
39 | @IsTest
40 | static void maximum_retries_exceeded() {
41 | RetryableMock job = RetryableMock.newInstance(false);
42 | JobResult result;
43 | while (!job.isLastTry()){
44 | result = job.retry();
45 | }
46 | System.assert(result.status == Retryable.Status.MAX_RETRIES);
47 | System.assert(job.isLastTry() == true);
48 | System.assert(job.getNextTry() == null);
49 | }
50 |
51 | @IsTest
52 | static void next_retry() {
53 | RetryableMock job = RetryableMock.newInstance(false);
54 | System.assert(job.getNextTry() == null);
55 | job.retry();
56 | System.assert(job.getNextTry() == job.getFirstExecution().addMinutes(10));
57 | job.retry();
58 | System.assert(job.getNextTry() == job.getFirstExecution().addMinutes(30));
59 | }
60 |
61 | }
--------------------------------------------------------------------------------
/force-app/main/default/classes/BatchableRetryTest.cls:
--------------------------------------------------------------------------------
1 | @IsTest
2 | public with sharing class BatchableRetryTest {
3 | static Logger log = new Logger(BatchableRetryTest.class.getName());
4 |
5 |
6 | private static void insertSucceededJob(Retryable job) {
7 | insertJob(job, Retryable.Status.SUCCEEDED, 1);
8 | }
9 |
10 | private static void insertJob(Retryable job, Retryable.Status status, Integer count) {
11 | List jobs = new List{
12 | job
13 | };
14 | insertJobs(jobs, status, count);
15 | }
16 |
17 | private static void insertJobs(List jobs, Retryable.Status status, Integer count) {
18 | List jobsToInsert = new List();
19 | for (Retryable job : jobs) {
20 | String serializedJob = JSON.serialize(job);
21 | String className = String.valueOf(job).split(':')[0];
22 | RetryableJob__c failedJob = new RetryableJob__c(
23 | count__c = count,
24 | className__c = className,
25 | serializedJob__c = serializedJob,
26 | nextTry__c = job.getFirstExecution(),
27 | firstTry__c = job.getFirstExecution(),
28 | status__c = status.name()
29 | );
30 | jobsToInsert.add(failedJob);
31 | }
32 |
33 | insert jobsToInsert;
34 | }
35 |
36 | @IsTest
37 | static void no_jobs_waiting() {
38 | RetryableMock job = RetryableMock.newInstance(true);
39 | insertSucceededJob(job);
40 | Test.startTest();
41 | BatchableRetry batch = new BatchableRetry();
42 | Database.executeBatch(batch, 1);
43 | Test.stopTest();
44 | List failedCallouts = [
45 | SELECT Id, nextTry__c, status__c
46 | FROM RetryableJob__c
47 | ];
48 | System.assert(failedCallouts.size() == 1);
49 | System.assert(failedCallouts[0].status__c == Retryable.Status.SUCCEEDED.name());
50 | }
51 |
52 |
53 | @IsTest
54 | static void all_jobs_succeed() {
55 | Integer numberOfJobs = 200;
56 | List jobs = new List();
57 | for (Integer i = 0; i < numberOfJobs; i++) {
58 | RetryableMock job = RetryableMock.newInstance(true);
59 | jobs.add(job);
60 | }
61 | insertJobs(jobs, Retryable.Status.FAILED_RETRY, 0);
62 | Test.startTest();
63 | BatchableRetry batch = new BatchableRetry();
64 | Database.executeBatch(batch, numberOfJobs);
65 | Test.stopTest();
66 | List succeededJobs = [
67 | SELECT Id, nextTry__c, status__c, firstTry__c
68 | FROM RetryableJob__c
69 | ];
70 | System.assert(succeededJobs.size() == numberOfJobs);
71 | for (RetryableJob__c callout : succeededJobs) {
72 | System.assert(callout.status__c == Retryable.Status.SUCCEEDED.name());
73 | }
74 | }
75 |
76 | @IsTest
77 | static void all_jobs_fail() {
78 | Integer numberOfJobs = 100;
79 | List jobs = new List();
80 | for (Integer i = 0; i < numberOfJobs; i++) {
81 | RetryableMock job = RetryableMock.newInstance(false);
82 | jobs.add(job);
83 | }
84 | insertJobs(jobs, Retryable.Status.FAILED_RETRY, 1);
85 | Test.startTest();
86 | BatchableRetry batch = new BatchableRetry();
87 | Database.executeBatch(batch, 200);
88 | Test.stopTest();
89 | List failedJobs = [
90 | SELECT Id, nextTry__c, count__c, status__c, firstTry__c
91 | FROM RetryableJob__c
92 | WHERE status__c = :Retryable.Status.FAILED_RETRY.name()
93 | ];
94 | System.assertEquals( numberOfJobs, failedJobs.size());
95 | RetryableJob__c job = failedJobs[0];
96 | System.assertEquals(2, job.count__c);
97 | System.assertEquals(jobs[0].getFirstExecution().addMinutes(10), job.nextTry__c);
98 | }
99 |
100 | @IsTest
101 | static void callouts_returning_failed_retry_status() {
102 | Retryable.Status expectedStatus = Retryable.Status.FAILED_RETRY;
103 | Integer batchSize = 10;
104 | insertCallouts(batchSize, expectedStatus);
105 | Test.startTest();
106 | HttpCalloutMock mock = new HTTPMockFactory(501, 'NOK', '{"response":"A retryable error occurred"}', new Map());
107 | Test.setMock(HttpCalloutMock.class, mock);
108 | BatchableRetry batch = new BatchableRetry();
109 | Database.executeBatch(batch, batchSize);
110 | Test.stopTest();
111 | List failedCallOuts = [
112 | SELECT Id, nextTry__c, count__c
113 | FROM RetryableJob__c
114 | WHERE status__c = :expectedStatus.name()
115 | ];
116 | System.assertEquals(batchSize, failedCallOuts.size());
117 | }
118 |
119 | @IsTest
120 | static void callouts_returning_action_required() {
121 | Retryable.Status expectedStatus = Retryable.Status.FAILED_ACTION_REQUIRED;
122 | Integer batchSize = 10;
123 | insertCallouts(batchSize, expectedStatus);
124 | Test.startTest();
125 | HttpCalloutMock mock = new HTTPMockFactory(401, 'NOK', '{"response":"Authorization required"}', new Map());
126 | Test.setMock(HttpCalloutMock.class, mock);
127 | BatchableRetry batch = new BatchableRetry();
128 | Database.executeBatch(batch, batchSize);
129 | Test.stopTest();
130 | List failedCallOuts = [
131 | SELECT Id, nextTry__c, count__c
132 | FROM RetryableJob__c
133 | WHERE status__c = :expectedStatus.name()
134 | ];
135 | System.assertEquals(batchSize, failedCallOuts.size());
136 | }
137 |
138 | @IsTest
139 | static void callouts_returning_succeeded() {
140 | Retryable.Status expectedStatus = Retryable.Status.SUCCEEDED;
141 | Integer batchSize = 10;
142 | insertCallouts(batchSize, expectedStatus);
143 | Test.startTest();
144 | HttpCalloutMock mock = new HTTPMockFactory(201, 'OK', '{"response":"all done"}', new Map());
145 | Test.setMock(HttpCalloutMock.class, mock);
146 | BatchableRetry batch = new BatchableRetry();
147 | Database.executeBatch(batch, batchSize);
148 | Test.stopTest();
149 | List failedCallOuts = [
150 | SELECT Id, nextTry__c, count__c
151 | FROM RetryableJob__c
152 | WHERE status__c = :expectedStatus.name()
153 | ];
154 | System.assertEquals(batchSize, failedCallOuts.size());
155 | }
156 |
157 | private static void insertCallouts(Integer count, Retryable.Status status) {
158 | List jobs = new List();
159 | for (Integer i = 0; i < count; i++) {
160 | Retryable job = new RetryableMockCallout();
161 | jobs.add(job);
162 | }
163 | insertJobs(jobs, Retryable.Status.FAILED_RETRY, 1);
164 | }
165 | }
166 |
--------------------------------------------------------------------------------