AWS Developer Tools Blog

Announcing overridable client configuration in the AWS SDK for Kotlin

We’re excited to announce that the AWS SDK for Kotlin now supports overridable client configuration. You can use this feature to execute AWS service calls with specialized config that differs from the config provided when the client was initialized. This unlocks new capabilities and increases the flexibility of your code. In this post, I will discuss the capabilities, show code examples, and explain some new best practices. Let’s dive in!

Initializing a client

All service clients require configuration at initialization time. If you don’t provide configuration explicitly, it may be detected from your runtime environment via shared configuration files, system properties, environment variables, or other sources. Whether explicitly specified or implicitly detected, the service client internally tracks all its required config after initialization.

Let’s consider an example with Amazon DynamoDB:

DynamoDbClient.fromEnvironment {
    // explicit config
    logMode = LogMode.LogResponse
    region = "us-west-2"
}.use { ddbClient ->

    // print all table names
    ddbClient.listTables().tableNames?.forEach(::println)

    // print first page of items from "dictionary" table
    ddbClient.scan { tableName = "dictionary" }.items?.forEach(::println)
}

In the preceding example, I explicitly set the log mode and AWS region. The client then determines values for the remaining config fields such as credentials provider (typically the default credential provider chain), retry strategy (typically the standard retry strategy), and so on.

Every AWS service call I execute from ddbClient (for example, the listTables and scan calls) will use the same configuration that was resolved at initialization time. Normally this is fine because usually I want to make calls in the same region, with the same credentials.

Sometimes, though, I may want to execute some service calls with slightly different config. To accomplish this, I will use the withConfig method on the service client. Let’s look at a few examples:

Changing retry behavior

I want to insert a bunch of items into a DynamoDB table in bulk, so I write a new function for use in my previous example:

suspend fun bulkLoadWords(ddbClient: DynamoDbClient, fromFile: File) {
    fromFile
        .readLines()
        .windowed(25) // 25 is the max number of DynamoDB batch items
        .forEach { page ->
            ddbClient.batchWriteItem {

                // transform file lines to WriteRequest elements
                val items = page.map { word ->
                    WriteRequest {
                        putRequest {
                            item = mapOf(
                                "language" to AttributeValue.S("en"),
                                "word" to AttributeValue.S(word),
                            )
                        }
                    }
                }

                requestItems = mapOf("dictionary" to items)
            }
        }
}

If my table’s write capacity is too low and the items are inserted too fast, I may see throttling exceptions. The AWS SDK for Kotlin uses a standard retry strategy by default, which tries a call up to 3 times. For the bulk insert calls, I can increase the number of tries to 10 before calling bulkLoadWords:

// using a client with more retries...
ddbClient.withConfig {
    retryStrategy { maxAttempts = 10 }
}.use { patientDdbClient ->

    // ...bulk insert items from file
    bulkLoadWords(patientDdbClient, File("/tmp/words.txt"))
}

Using withConfig allows me to retain the settings from my existing ddbClient (specifically, log mode and region) but override specific settings (in this case, the retry attempts). The withConfig function returns a new client instance and the use function allows me to scope that overridden client’s lifetime to a small block. That is to say, the patientDdbClient will be closed when the use block ends.

Enabling additional logging

Sometimes, it may be useful to enable additional logging on a subset of service calls for auditing or debugging purposes.

Let’s consider an example using AWS Lambda:

LambdaClient.fromEnvironment {
    region = "us-west-2"
}.use { lambdaClient ->

    // invoke "sum" function
    lambdaClient.invoke {
        functionName = "sum"
        payload = """ { "x": 2, "y": 3 } """.toByteArray()
    }

    // using a client with more logging...
    lambdaClient.withConfig {
        logMode = LogMode.LogRequest + LogMode.LogResponse
    }.use { loggingLambdaClient ->

        // ...invoke "divide" function
        loggingLambdaClient.invoke {
            functionName = "divide"
            payload = """ { "x": 5, "y": 0 } """.toByteArray()
        }
    }
}

In the preceding example, my first Lambda invoke call uses the configuration of lambdaClient. The second invoke call uses the overridden configuration, which sets the more verbose wire logging mode.

Switching regions

AWS service calls are almost always directed to regional endpoints. Consequently, service clients are always initialized to a region—either explicitly provided or implicitly detected from the environment (such as when running on AWS Lambda or Amazon EC2 instances, which are themselves regionalized). Sometimes, however, it may be useful to switch regions to access resources which are in a different region.

Let’s consider an example using Amazon Simple Storage Service (Amazon S3):

data class Upload(val fileName: String, val region: String, val bucket: String)

val uploads = listOf(
    Upload("q3-report.pdf"    , "us-west-2", "quarterly-reports"),
    Upload("q4-report.pdf"    , "us-west-2", "quarterly-reports"),
    Upload("us-financials.zip", "us-east-1", "us-datasets"      ),
    Upload("eu-financials.zip", "eu-west-1", "eu-datasets"      ),
)

S3Client.fromEnvironment { 
    region = "us-west-2"
}.use { s3Client ->
    
    // upload the files from the list
    uploads.forEach { upload ->
        s3Client.withConfig { region = upload.region }.use { regionalS3Client ->
            val file = File("/tmp/${upload.fileName}")
            
            regionalS3Client.putObject { 
                bucket = upload.bucket
                key = upload.fileName
                body = ByteStream.fromFile(file)
            }
        }
    }
    
}

In the preceding example, I first define a list of uploads which includes information about the local name of the file, the region to which they should be uploaded, and the bucket name. I then initialize an S3 client and iterate over the list of uploads. For each upload, I invoke putObject using a regionalized S3 client created by withConfig for the desired region.

Best practices

Overriding client config unlocks some powerful use cases. Here are some tips to make the most of them:

  • Use withConfig instead of creating additional clients from scratch. Creating a new service client with fromEnvironment detects configuration from your local environment. The creation process may read profiles from the filesystem or make HTTP calls to resolve required configuration values. Although creating a service client with a constructor does not detect configuration, as when fromEnvironment is used, it still may read from the file system. Neither approach, however, reuses configuration values from already-running client. For that reason, we recommend using withConfig to create additional service clients when you already have a configured one available. Initialization is much faster and all your existing config is copied to the new client—allowing you to specify only what is different.
  • Scope clients with the use function. Calling withConfig creates a new client instance. It’s faster than creating a client from scratch with a constructor or fromEnvironment but it shares references to certain long-running resources such as credentials providers, an HTTP client engine, etc. Always wrap your usage of these overridden clients in use to ensure that they’re properly closed at the end, which frees references to shared resources. Alternatively, you can call close on the overridden client directly.

More information

Now that you’ve seen how to override client configuration, we hope you’ll try it out in your own code! The following resources can provide more details to get you started:

If you have any questions about how to integrate this feature into your code, feel free to leave a comment below or start a discussion in our GitHub repo. If you encounter a bug or incorrect documentation, please file an issue. Finally, we’re eager to hear your thoughts about the SDK and this new feature in our developer survey!

About the author:

Ian Botsford

Ian Botsford

Ian is a developer working on the AWS SDK for Kotlin. He is passionate about making AWS easy to use through fluent, idiomatic SDKs. You can find him on GitHub at @ianbotsf.