AWS Developer Tools Blog

Waiters in modular AWS SDK for JavaScript

On December 15th, 2020, we announced the general availability of the AWS SDK for JavaScript, version 3 (v3). In v3, the waiters are modular and not part of the client class. In this blog post, we cover the differences between waiters in v2 and v3, and how to use them.

Background

Waiters make it easier to wait for a resource to transition into a desired state. This is a very common task when you’re working with services that are eventually consistent such as Amazon DynamoDB or have a lead time for creating resources such as Amazon EC2. Before waiters, it was difficult to come up with the polling logic to determine whether a particular resource had transitioned into a desired state. With waiters you can more simply and easily abstract out the polling logic into a simple API call.

In v2, the waiters are bundled with the client. This increases the package size for the client. When your code does not call eventually consistent operations, you do not need waiters. In v3, the waiters are modular. You will import them only when they’re required, thus reducing your bundle size and improving performance.

Usage

Waiters in v2

In the example below, you create an S3 client using v2. Since the CreateBucket operation is eventually consistent, you need to call a waiter using client.waitFor("bucketExists") before the operations can be performed on the bucket.

import AWS from "aws-sdk";

const Bucket = "BUCKET_NAME";
const client = new AWS.S3({ region: "REGION" });

await client.createBucket({ Bucket }).promise();
await client.waitFor("bucketExists", { Bucket });

// Perform operations on Bucket, like PutObject.

Waiters in v3

In v3, the bare-bones modular client doesn’t have waitFor operation bundled with it. You need to import the modular waitUntilBucketExists operation to check for consistency of the newly created bucket before performing operations on it.

import {
  S3Client, CreateBucketCommand, waitUntilBucketExists
} from "@aws-sdk/client-s3";

const Bucket = "BUCKET_NAME";
const client = new S3Client({ region: "REGION" });
const command = new CreateBucketCommand({ Bucket });

await client.send(command);
await waitUntilBucketExists({ client, maxWaitTime: 60 }, { Bucket });

// Perform operations on Bucket, like PutObject.

If you are looking for information on waiters in v3, you can jump to sections on configuration options and error handling.

Logging polling delays

In the metrics shared below in this blog post, we console.log certain values in the SDK code to understand how waiters function. We also call the bucketExists waiter for a bucket which doesn’t exist in client code.

Log polling delay between attempts in v2

In order to test default delay in v2, we update the code in node_modules/aws-sdk/lib/resource_waiter.js to console.log when RETRY_CHECK event listener is called.

   listeners: new AWS.SequentialExecutor().addNamedListeners(function(add) {
     add('RETRY_CHECK', 'retry', function(resp) {
       var waiter = resp.request._waiter;
       if (resp.error && resp.error.code === 'ResourceNotReady') {
         resp.error.retryDelay = (waiter.config.delay || 0) * 1000;
       }
+      console.log(`Delay: ${(resp.error.retryDelay/1000).toFixed(3)}s`);
    });

The v2 client code which calls bucketExists waiter for bucket which doesn’t exist.

import AWS from "aws-sdk";

const region = "us-west-2";
const client = new AWS.S3({ region });

const Bucket = "test-waiters-bucket-does-not-exist";
await client.waitFor("bucketExists", { Bucket }).promise();

Log polling delay between attempts in v3

In order to test delays in v3, we update the code in node_modules/@aws-sdk/util-waiter/dist/cjs/poller.js to console.log before sleep is called.

+   console.log(`Delay: ${delay.toFixed(3)}s`);
    await sleep_1.sleep(delay);
    const { state } = await acceptorChecks(client, input);

The v3 client code which calls bucketExists waiter for bucket which doesn’t exist.

import { S3, waitUntilBucketExists } from "@aws-sdk/client-s3";

const region = "us-west-2";
const client = new S3({ region });

const Bucket = "test-waiters-bucket-does-not-exist";
await waitUntilBucketExists({ client, maxWaitTime: 60 }, { Bucket });

Waiting Strategies

The v2 follows linear backoff while waiting, while v3 follows exponential backoff with full jitter.

The exponential backoff with full jitter balances the cost to the caller spent waiting on a resource to stabilize, the cost of the service in responding to polling requests, and the overhead associated with potentially violating a service level agreement and getting throttled. Waiters that poll for resources that quickly stabilize will complete within the first few calls, where as waiters that could take hours to complete will send fewer requests as the number of retries increases.

By generally increasing the amount of delay between retries as the number of retry attempts increases, waiters will not overload services with unnecessary polling calls, and it protects customers from violating service level agreements that could counter-intuitively cause waiters to take longer to complete or even fail due to request throttling. By introducing randomness with jitter, waiters will retry slightly more aggressively to improve the time to completion while still maintaining the general increase in delay between retries.

Linear backoff in v2

On examining the output after running client code, you will notice that a retry call is made every 5 seconds – a delay defined by S3 service for bucketExists waiter.

Delay: 5.000s
Delay: 5.000s
Delay: 5.000s

Exponential backoff with full jitter in v3

On examining the output after running client code, you will notice that the polling is done with exponential backoff starting with 5 seconds – a minDelay defined by S3 service for bucketExists waiter.

Delay: 5.000s
Delay: 5.264s
Delay: 10.851s
Delay: 16.076s

Because waiters use jitter, you will get different values in your run of the example code.

Setting delay between request polling

Both v2 and v3 allow you to define delay between polling requests.

Optional configuration delay in v2

Since v2 uses linear backoff, one value – delay – is used for the wait time after each poll. The waiter will use defaults provided by the service if value is not defined in waiter configuration.

To test delay value, we pass custom delay of 2 seconds in waiter configuration:

...
  await client
    .waitFor("bucketExists", { Bucket, $waiter: { delay: 2 } })
    .promise();
...

On examining the output, you will notice that a retry call is made every 2 seconds:

Delay: 2.000s
Delay: 2.000s
Delay: 2.000s

Optional configuration minDelay and maxDelay in v3

Since v3 uses exponential backoff with full jitter, two values – minDelay and maxDelay – are used for the wait time to be computed after each poll. The waiter will use defaults provided by the service if values are not defined in the waiter configuration.

And we pass custom minDelay and maxDelay values of 1 seconds and 10 seconds respectively in waiter configuration

...
  await waitUntilBucketExists({
    client,
    maxWaitTime: 60,
    minDelay: 1,
    maxDelay: 10,
  }, { Bucket });
...

On examining the log files, you will notice that a polling is done with a minimum delay for 1 second and maximum delay of 10 seconds as per values defined in waiter configuration.

Delay: 1.000s
Delay: 1.257s
Delay: 3.202s
Delay: 6.744s
Delay: 10.000s

If you provide the same value for minDelay and maxDelay, then v3 will use linear backoff.

In the following code, we pass same value of 2 to minDelay as well as maxDelay:

...
  await waitUntilBucketExists({
    client,
    maxWaitTime: 60,
    minDelay: 2,
    maxDelay: 2,
  }, { Bucket });
...

On examining the output, you will notice that a polling is done every 2 seconds:

Delay: 2.000s
Delay: 2.000s
Delay: 2.000s

Setting maximum wait time for waiter

In v2, there is no direct way to provide maximum wait time for a waiter. You need to configure delay and maxAttempts to indirectly suggest the maximum time you want the waiter to run for. In v3, a required configuration maxWaitTime needs to be passed while calling the waiter.

Optional configuration maxAttempts in v2

To test maximum wait time of 30 seconds, in v2 we configure delay for 5 seconds and maxAttempts for 6 attempts.

...
  await client
    .waitFor("bucketExists", { Bucket, $waiter: { delay: 5, maxAttempts: 6 } })
    .promise();
...

On examining the output, you’ll see that 5 attempts are made before the waiter fails.

Delay: 5.000s
Delay: 5.000s
Delay: 5.000s
Delay: 5.000s
Delay: 5.000s
Delay: 5.000s
/local/home/trivikr/workspace/test-waiters/node_modules/aws-sdk/lib/services/s3.js:698
      resp.error = AWS.util.error(new Error(), {
                                  ^
NotFound: Resource is not in the state bucketExists

Required configuration maxWaitTime in v3

To test the maximum wait time of 30 seconds, in v3 we pass maxWaitTime=30 in waiter configuration.

...
  await waitUntilBucketExists({ client, maxWaitTime: 30 }, { Bucket });
...

On examining the output, you’ll see that 3 attempts are made before waiter fails because maximum wait time is reached.

Delay: 5.000s
Delay: 8.578s
Delay: 7.310s
/local/home/trivikr/workspace/test-waiters/node_modules/@aws-sdk/util-waiter/dist/cjs/waiter.js:33
        const timeoutError = new Error(`${JSON.stringify({
                             ^
Error [TimeoutError]: {"state":"TIMEOUT","reason":"Waiter has timed out"}

Aborting waiter in v3 using AbortController

In v2, there is no way to abort waiting once the waiter is called. In v3, you can pass abortSignal while calling the waiter and call abort at any point in time.

AbortController Interface

The AbortController Interface provides an abort() method that toggles the state of a corresponding AbortSignal object. The API that wants to support aborting can accept an AbortSignal object, and is encouraged to respond to abort() by rejecting any unsettled promise with an "AbortError".

// Returns a new controller whose signal is set to a newly created AbortSignal object.
const controller = new AbortController();

// Returns AbortSignal object associated with the controller.
const signal = controller.signal;

// Invoking this method will set controller’s AbortSignal's aborted flag
// and signal to any observers that the associated activity is to be aborted.
controller.abort();

Usage

In v3, we added an implementation of WHATWG AbortController interface in @aws-sdk/abort-controller. To use it, you need to send AbortController.signal as abortSignal in waiter configuration when calling .waitUntilOperationName() operation as follows:

import { AbortController } from "@aws-sdk/abort-controller";
import { S3Client, waitUntilBucketExists } from "@aws-sdk/client-s3";

...

const client = new S3Client(clientParams);
const Bucket = "test-waiters-bucket-does-not-exist";

// The abortController can be aborted any time.
// The waiter will not send any requests if abortSignal is already aborted.
// The existing request created by waiter will be destroyed if abortSignal
// is aborted before response is returned.
const abortController = new AbortController();

const waiterPromise = waitUntilBucketExists(
  { client, maxWaitTime: 60, abortSignal: abortController.signal },
  { Bucket }
);

// Abort after 3 seconds.
setTimeout(() => {
  abortController.abort();
}, 3000);

// This will fail with "AbortError" in 3 seconds when abortSignal is aborted.
await waiterPromise;

Alternatively, you can substitute AbortController in @aws-sdk/abort-controller with any other WHATWG AbortController interface, like AbortController Web API or abort-controller npm package or Node.js AbortController implementation.

Error Handling

The errors thrown by waiters can be handled using try…catch statements. Since the waiter returns a promise, you can also use Promise.prototype.catch(). In v2, the waiters also support callbacks which are not supported in v3. The section below will show examples with try..catch statements.

Error Handling in v2

In v2, the waiter re-throws the error returned by the last operation which was attempted. For example, the following code shows Timeout Error:

// ...
const Bucket = "test-waiters-bucket-does-not-exist";
try {
  await client
    .waitFor("bucketExists", { Bucket, $waiter: { delay: 2, maxAttempts: 3 } })
    .promise();
} catch (error) {
  // error.name is "NotFound" and error.code is "ResourceNotReady" from the
  // pooling operation headBucket used for waiting
}

Error Handling in v3

In v3, the waiter throws a specific error with information about the state of the waiter when the error was thrown. For example, the following code shows Timeout Error:

// ...
const Bucket = "test-waiters-bucket-does-not-exist";
try {
  await waitUntilBucketExists({ client, maxWaitTime: 10 }, { Bucket });
} catch (error) {
  // error.name is "TimeoutError" and error.state is "TIMEOUT"
}

Feedback

We value your feedback, so please tell us what you like and don’t like by opening an issue on GitHub.

Trivikram Kamat

Trivikram Kamat

Trivikram is maintainer of AWS SDK for JavaScript in Node.js and browser. Trivikram is also a Node.js Core collaborator and have contributed to HTTP, HTTP/2 and HTTP/3 over QUIC implementations in the past. He has been writing JavaScript for over a decade. You can find him on Twitter @trivikram and GitHub @trivikr.