bruno@bpaulino: ~/posts/retrying-api-calls-with-exponential-backoff.md

bruno@bpaulino:~/posts$ cat retrying-api-calls-with-exponential-backoff.md

Retrying API Calls with Exponential Backoff in JavaScript

2021 MAR 01
OUTLINE

Have you ever implemented an integration with a third-party service where you have to call their API endpoints several times a day? Depending on the number of times you call this API, some of those calls will inevitably fail.

One solution to mitigate this problem is to implement a retry algorithm. Here is a sequence diagram showing how this algorithm could look like:

Exponential backoff diagram

Notice that once our API call fails, our app immediately tries to call it again. That could be extremely fast and there is nothing wrong with that, but that isn’t very effective. Why?

Being polite with Exponential Backoff

Lets assume the restaurants API we were trying to call on the diagram above is having some trouble. Maybe it’s overloaded or is completely down. Retrying to call it immediately after a failed attempt will do no good. It will actually make the situation worse: The restaurants API will be hammered harder and won’t have time to recover.

To countermeasure that, we can wait a little before retries. We can actually do better than that. What if on every failed attempt, we exponentially increase the waiting time for the next attempt? Bingo, This is what Exponential Backoff is.

  • Our app tries to call the Restaurants API.
  • The API call fails.
  • Our app waits for 200 millisecods before calling it again.
  • Our app retries to call the Restaurants API again.
  • The API call fails again.
  • Our app waits for 400 millisecods before calling it again.
  • Our app retries to call the Restaurants API again.
  • The API call completes successfully.

Here is how the diagram would look like when we implement Exponential Backoff:

Exponential backoff diagram

How can we do that in Javascript?

The implementation of the algorithm above is actually quite straightforward in Javascript. The implementation below works in Node.js and also in modern browsers, with zero dependencies.

JS
1/**
2 * Wait for the given milliseconds
3 * @param {number} milliseconds The given time to wait
4 * @returns {Promise} A fulfilled promise after the given time has passed
5 */
6function waitFor(milliseconds) {
7 return new Promise((resolve) => setTimeout(resolve, milliseconds));
8}
9 
10/**
11 * Execute a promise and retry with exponential backoff
12 * based on the maximum retry attempts it can perform
13 * @param {Promise} promise promise to be executed
14 * @param {function} onRetry callback executed on every retry
15 * @param {number} maxRetries The maximum number of retries to be attempted
16 * @returns {Promise} The result of the given promise passed in
17 */
18function retry(promise, onRetry, maxRetries) {
19 // Notice that we declare an inner function here
20 // so we can encapsulate the retries and don't expose
21 // it to the caller. This is also a recursive function
22 async function retryWithBackoff(retries) {
23 try {
24 // Make sure we don't wait on the first attempt
25 if (retries > 0) {
26 // Here is where the magic happens.
27 // on every retry, we exponentially increase the time to wait.
28 // Here is how it looks for a `maxRetries` = 4
29 // (2 ** 1) * 100 = 200 ms
30 // (2 ** 2) * 100 = 400 ms
31 // (2 ** 3) * 100 = 800 ms
32 const timeToWait = 2 ** retries * 100;
33 console.log(`waiting for ${timeToWait}ms...`);
34 await waitFor(timeToWait);
35 }
36 return await promise();
37 } catch (e) {
38 // only retry if we didn't reach the limit
39 // otherwise, let the caller handle the error
40 if (retries < maxRetries) {
41 onRetry();
42 return retryWithBackoff(retries + 1);
43 } else {
44 console.warn("Max retries reached. Bubbling the error up");
45 throw e;
46 }
47 }
48 }
49 
50 return retryWithBackoff(0);
51}

And here is how you can quickly test this implementation:

JS
1/** Fake an API Call that fails for the first 3 attempts
2 * and resolves on its fourth attempt.
3 */
4function generateFailableAPICall() {
5 let counter = 0;
6 return function () {
7 if (counter < 3) {
8 counter++;
9 return Promise.reject(new Error("Simulated error"));
10 } else {
11 return Promise.resolve({ status: "ok" });
12 }
13 };
14}
15 
16/*** Testing our Retry with Exponential Backoff */
17async function test() {
18 const apiCall = generateFailableAPICall();
19 const result = await retry(
20 apiCall,
21 () => {
22 console.log("onRetry called...");
23 },
24 4
25 );
26 
27 assert(result.status === "ok");
28}
29 
30test();

If you want to try this out, here is a Codesanbox link where you can play with it.

↗ view raw .md

bruno@bpaulino:~/posts$ cd