├── 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 | --------------------------------------------------------------------------------