Or press ESC to close.

Enhancing Automation Reliability with Retry Patterns

June 15th 2023 9 min read
medium
nodejs18.14.0
javascriptES6
api
ui
mobile
webdriverio8.11.2

In the world of automation testing, encountering hiccups during test execution is a common challenge. Whether it's due to intermittent network issues, slow-loading pages, or elements that temporarily go missing, such roadblocks can disrupt test flows and introduce false failures.

Fortunately, retry patterns offer a powerful solution to overcome these obstacles and improve the overall reliability of our automation scripts. In this article, we'll delve into the world of retry mechanisms, exploring different strategies and techniques that can help us build more resilient and robust automation workflows. So without further ado, let's get started.

Dynamic Retry

We will start with a pattern that can be seen quite often in automation testing. In this blog post, we called it "Dynamic Retry" because it reflects the ability of this technique to dynamically retry the specified set of actions until the maximum number of retries is reached.

                                     
async function dynamicRetry(actions, maxRetries) {
    let retryCount = 0;
                          
    async function retryAction() {
        try {
            return await actions();
        } catch (error) {
            if (retryCount < maxRetries) {
                retryCount++;
                return retryAction();
            } else {
                throw error;
            }
        }
    }
    return await retryAction();
}
                    

The dynamicRetry function is an asynchronous JavaScript function that allows for retrying an action multiple times if it fails. It accepts two parameters: actions, which is an asynchronous function, and maxRetries, representing the maximum number of retry attempts.

Within the function, it recursively calls the actions function and keeps track of the number of retries using a retryCount variable. If the action function fails and the number of retries is below the maximum limit, it retries the action. However, if the maximum number of retries is reached, it throws an error.

In the end, the function returns the result of the action function or throws an error if the maximum number of retries is exceeded.

To illustrate the function's usage, we will utilize the DEMOQA website and WebdriverIO framework. Within an it block, we will navigate to a webpage featuring multiple input fields. Our objective is to populate some of these fields and ultimately click a non-existent button. To allow for multiple attempts, we will set the number of retries to 3, ensuring that each action is repeated thrice before the case fails due to the button's absence.

                                     
it("Testing the dynamic retry pattern", async () => {
    await demoPage.navigateToDemoPageTextBoxes();
    await browser.pause(1000);
    await dynamicRetry(async () => {
        await demoPage.fillFullName("John Doe");
        await demoPage.fillEmail("test@gmail.com");
        await demoPage.fillCurrentAddress("test address");
        await demoPage.clickFakeButton();
    }, 3);
});
                    

The "Dynamic Retry" emphasizes the flexibility and adaptability of the retry mechanism in adjusting to different scenarios and conditions during the execution of the action.

Polling Retry

Another popular retry pattern is using timeouts and polling. This approach repeatedly checks if a set of actions is successfully completed or the timeout is reached. For instance:

                                     
async function pollRetry(actions, timeout, pollInterval) {
    const startTime = Date.now();
    let lastError;
                          
    while (Date.now() - startTime < timeout) {
        try {
            await actions();
            return;
        } catch (error) {
            lastError = error;
            await sleep(pollInterval);
        }
    }
                          
    throw lastError;
}
                          
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
                    

The function starts a timer and enters a loop. Inside the loop, it tries to execute the actions. If an error occurs during execution, the error is stored and the function waits for the specified pollInterval before retrying the actions.

The loop continues until either the actions are executed successfully or the total time elapsed reaches the specified timeout. If the actions are executed successfully, the function exits the loop. If the timeout is exceeded, the function throws the last encountered error.

An example call for the same block of code from the previous example would look like this:

                                     
it("Testing the poll retry pattern", async () => {
    await demoPage.navigateToDemoPageTextBoxes();
    await browser.pause(1000);
    await pollRetry(
        async () => {
            await demoPage.fillFullName("John Doe");
            await demoPage.fillEmail("test@gmail.com");
            await demoPage.fillCurrentAddress("test address");
            await demoPage.clickFakeButton();
        },
        20000,
        5000
    );
});
                    

Error Handling Retry

This pattern uses error handling techniques to catch specific errors that occur when elements are not found or interacted with. We can use try-catch blocks to handle these errors and perform appropriate actions.

A simplified example could look like this:

                                     
function errorHandlingRetry(actions) {
    return new Promise(async (resolve, reject) => {
        try {
            await actions();
            resolve();
        } catch (error) {
            if (error instanceof ElementNotFoundError) {
                // Handle the case when the element is not found
            } else if (error instanceof TimeoutError) {
                // Handle the case when a timeout occurs
            } else {
                // Handle other types of errors
            }
            reject(error);
        }
    });
}
                    

In the above code, the errorHandlingRetry function takes a parameter actions, which is a function that encapsulates the block of actions to be executed. It returns a promise that resolves if the actions are completed successfully, and rejects with the error if any error occurs. Inside the function, the actions are executed using await actions(), and any errors that occur are caught and handled accordingly.

We can replace the comments in the if conditions with our desired error handling logic for each specific error type.

Now the error-handling logic can vary depending on our specific use case and the desired behavior for each type of error.

We can handle the ElementNotFoundError by, for example, logging an error message indicating that the expected element was not found. After that, we can retry the action after a short delay to allow the element some time to appear.

                                     
if (error instanceof ElementNotFoundError) {
    console.error("Element not found:", error.elementSelector);
    await sleep(1000);
    await actions();
}
                    

In case of a TimeoutError error, we can log an error message indicating that a timeout occurred while waiting for an action to complete and retry the action after a delay.

                                     
else if (error instanceof TimeoutError) {
    console.error("Timeout occurred:", error.message);
    await sleep(1000);
    await actions();
}
                    

And in case of any other error, let's just log an error message.

                                     
else {
    console.error("An error occurred:", error);
}
                    

Remember to adjust the delay time and error handling logic based on your application's requirements and the specific behavior you want to achieve.

A call to the error handling retry function for our previous example would look something like this:

                                     
it("Testing the error handling retry pattern", async () => {
    await demoPage.navigateToDemoPageTextBoxes();
    await browser.pause(1000);
    await errorHandlingRetry(async () => {
        await demoPage.fillFullName("John Doe");
        await demoPage.fillEmail("test@gmail.com");
        await demoPage.fillCurrentAddress("test address");
        await demoPage.clickFakeButton();
    });
});
                    

Exponential Backoff

An exponential backoff is a retry strategy where the interval between retries increases exponentially with each attempt. This approach helps alleviate congestion and reduces the load on the system. Here's an example implementation:

                                     
async function retryWithExponentialBackoff(actions, maxRetries, initialDelay) {
    let retryCount = 0;
    let delay = initialDelay;
                          
    while (retryCount < maxRetries) {
        try {
            await actions();
            return;
        } catch (error) {
            console.error("Error:", error);
        }
        await sleep(delay);
        delay *= 2;
        retryCount++;
    }
    throw new Error(
        "Retry limit exceeded: Actions did not complete successfully."
    );
}
                    

The function executes the actions and waits for them to complete. If the actions are successful, the function returns. If an error occurs, it logs the error message to the console and waits for a specific amount of time determined by the delay variable. The delay is initially set to the initialDelay value and is doubled after each retry attempt. The function keeps track of the number of retries using the retryCount variable.

The function continues retrying the actions until either the maximum number of retries, specified by maxRetries, is reached or the actions are complete successfully. If the maximum number of retries is exceeded, the function throws an error with a message indicating that the actions did not complete successfully.

An example use case could look like this:

                                     
it("Testing the retry with exponential backoff pattern", async () => {
    await demoPage.navigateToDemoPageTextBoxes();
    await browser.pause(1000);
    await retryWithExponentialBackoff(
        async () => {
            await demoPage.fillFullName("John Doe");
            await demoPage.fillEmail("test@gmail.com");
            await demoPage.fillCurrentAddress("test address");
            await demoPage.clickFakeButton();
        },
        4,
        1000
    );
});
                    

Randomized Interval

Let's finish the post with one not seen very often.

A randomized interval adds an element of randomness to the retry mechanism, which can help avoid synchronization issues and patterns in the execution. Here's an example:

                                     
async function retryWithRandomizedInterval(
    actions,
    maxRetries,
    minDelay,
    maxDelay
) {
    let retryCount = 0;
                          
    while (retryCount < maxRetries) {
        try {
            await actions();
            return;
        } catch (error) {
            console.error("Error:", error);
        }
        const delay = Math.floor(
            Math.random() * (maxDelay - minDelay + 1) + minDelay
        );
        await sleep(delay);
        retryCount++;
    }
    throw new Error(
        "Retry limit exceeded: Actions did not complete successfully."
    );
}
                    

Inside the function, a retryCount variable is initialized to 0 to keep track of the number of retries. The function then enters a loop that continues until the retryCount reaches the maxRetries limit. In each iteration of the loop, the actions are executed by calling the actions function using await, and if the actions complete successfully, the function returns.

If an error occurs during the execution of the actions, the error is logged to the console. After catching the error, a random delay is generated using Math.random() and the provided minDelay and maxDelay values. The sleep function is then called with the generated delay to pause the execution of the function for that duration. Finally, the retryCount is incremented.

If the maxRetries limit is reached without the actions completing successfully, an error is thrown with a custom message.

An example use case could look something like this:

                                     
it("Testing the retry with a randomized interval pattern", async () => {
    await demoPage.navigateToDemoPageTextBoxes();
    await browser.pause(1000);
    await retryWithRandomizedInterval(
        async () => {
            await demoPage.fillFullName("John Doe");
            await demoPage.fillEmail("test@gmail.com");
            await demoPage.fillCurrentAddress("test address");
            await demoPage.clickFakeButton();
        },
        3,
        1000,
        3000
    );
});
                    

Conclusion

Retry patterns offer valuable mechanisms for improving automation reliability.



Key retry patterns include: loop-based retries, exponential backoff and randomized intervals.



Implementing appropriate retry patterns leads to more resilient and stable automation workflows.

Don't forget that all the complete code examples are available in our GitHub repository. Until next time.