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 whenfromEnvironment
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 usingwithConfig
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. CallingwithConfig
creates a new client instance. It’s faster than creating a client from scratch with a constructor orfromEnvironment
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 inuse
to ensure that they’re properly closed at the end, which frees references to shared resources. Alternatively, you can callclose
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:
- Developer guide entry on overriding client configuration
- Configuration options in the AWS SDK for Kotlin
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: