Skip to content

Commit 1c6bf98

Browse files
authored
bugfix: correct handling of non-success errors in ecs credential provider (#710)
1 parent f163699 commit 1c6bf98

File tree

4 files changed

+167
-31
lines changed

4 files changed

+167
-31
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"id": "5c8b043d-ea3d-43da-9db3-9978ddf9b9ff",
3+
"type": "bugfix",
4+
"description": "Correct handling of non-success responses when retrieving credentials on ECS.",
5+
"issues": [
6+
"awslabs/aws-sdk-kotlin#697"
7+
]
8+
}

aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/EcsCredentialsProvider.kt

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ package aws.sdk.kotlin.runtime.auth.credentials
88
import aws.sdk.kotlin.runtime.config.AwsSdkSetting
99
import aws.sdk.kotlin.runtime.config.AwsSdkSetting.AwsContainerCredentialsRelativeUri
1010
import aws.sdk.kotlin.runtime.config.resolve
11-
import aws.smithy.kotlin.runtime.ServiceException
11+
import aws.smithy.kotlin.runtime.ErrorMetadata
1212
import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials
1313
import aws.smithy.kotlin.runtime.auth.awscredentials.CredentialsProvider
1414
import aws.smithy.kotlin.runtime.client.ExecutionContext
@@ -172,21 +172,46 @@ public class EcsCredentialsProvider internal constructor(
172172

173173
private class EcsCredentialsDeserializer : HttpDeserialize<Credentials> {
174174
override suspend fun deserialize(context: ExecutionContext, response: HttpResponse): Credentials {
175+
if (!response.status.isSuccess()) {
176+
throwCredentialsResponseException(response)
177+
}
178+
175179
val payload = response.body.readAll() ?: throw CredentialsProviderException("HTTP credentials response did not contain a payload")
176180
val deserializer = JsonDeserializer(payload)
177-
return when (val resp = deserializeJsonCredentials(deserializer)) {
178-
is JsonCredentialsResponse.SessionCredentials -> Credentials(
179-
resp.accessKeyId,
180-
resp.secretAccessKey,
181-
resp.sessionToken,
182-
resp.expiration,
183-
PROVIDER_NAME,
184-
)
185-
is JsonCredentialsResponse.Error -> throw CredentialsProviderException("Error retrieving credentials from container service: code=${resp.code}; message=${resp.message}")
181+
val resp = deserializeJsonCredentials(deserializer)
182+
if (resp !is JsonCredentialsResponse.SessionCredentials) {
183+
throw CredentialsProviderException("HTTP credentials response was not of expected format")
186184
}
185+
186+
return Credentials(
187+
resp.accessKeyId,
188+
resp.secretAccessKey,
189+
resp.sessionToken,
190+
resp.expiration,
191+
PROVIDER_NAME,
192+
)
193+
}
194+
}
195+
196+
private suspend fun throwCredentialsResponseException(response: HttpResponse): Nothing {
197+
val errorResp = tryParseErrorResponse(response)
198+
val messageDetails = errorResp?.run { "code=$code; message=$message" } ?: "HTTP ${response.status}"
199+
200+
throw CredentialsProviderException("Error retrieving credentials from container service: $messageDetails").apply {
201+
sdkErrorMetadata.attributes[ErrorMetadata.ThrottlingError] = response.status == HttpStatusCode.TooManyRequests
202+
sdkErrorMetadata.attributes[ErrorMetadata.Retryable] =
203+
sdkErrorMetadata.isThrottling ||
204+
response.status.category() == HttpStatusCode.Category.SERVER_ERROR
187205
}
188206
}
189207

208+
private suspend fun tryParseErrorResponse(response: HttpResponse): JsonCredentialsResponse.Error? {
209+
if (response.headers["Content-Type"] != "application/json") return null
210+
val payload = response.body.readAll() ?: return null
211+
212+
return deserializeJsonCredentials(JsonDeserializer(payload)) as? JsonCredentialsResponse.Error
213+
}
214+
190215
private class EcsCredentialsSerializer(
191216
private val authToken: String? = null,
192217
) : HttpSerialize<Unit> {
@@ -209,14 +234,10 @@ internal class EcsCredentialsRetryPolicy : RetryPolicy<Any?> {
209234
}
210235

211236
private fun evaluate(throwable: Throwable): RetryDirective = when (throwable) {
212-
is ServiceException -> {
213-
val httpResp = throwable.sdkErrorMetadata.protocolResponse as? HttpResponse
214-
val status = httpResp?.status
215-
if (status?.category() == HttpStatusCode.Category.SERVER_ERROR) {
216-
RetryDirective.RetryError(RetryErrorType.ServerSide)
217-
} else {
218-
RetryDirective.TerminateAndFail
219-
}
237+
is CredentialsProviderException -> when {
238+
throwable.sdkErrorMetadata.isThrottling -> RetryDirective.RetryError(RetryErrorType.Throttling)
239+
throwable.sdkErrorMetadata.isRetryable -> RetryDirective.RetryError(RetryErrorType.ServerSide)
240+
else -> RetryDirective.TerminateAndFail
220241
}
221242
else -> RetryDirective.TerminateAndFail
222243
}

aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/JsonCredentialsDeserializer.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ internal sealed class JsonCredentialsResponse {
5353
/**
5454
* Response successfully parsed as an error response
5555
*/
56-
data class Error(val code: String, val message: String?) : JsonCredentialsResponse()
56+
data class Error(val code: String?, val message: String?) : JsonCredentialsResponse()
5757
}
5858

5959
/**
@@ -125,6 +125,6 @@ internal suspend fun deserializeJsonCredentials(deserializer: Deserializer): Jso
125125
if (expiration == null) throw InvalidJsonCredentialsException("missing field `Expiration`")
126126
JsonCredentialsResponse.SessionCredentials(accessKeyId!!, secretAccessKey!!, sessionToken!!, expiration!!)
127127
}
128-
else -> JsonCredentialsResponse.Error(code!!, message)
128+
else -> JsonCredentialsResponse.Error(code, message)
129129
}
130130
}

aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/EcsCredentialsProviderTest.kt

Lines changed: 118 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import aws.smithy.kotlin.runtime.http.request.url
2020
import aws.smithy.kotlin.runtime.http.response.HttpResponse
2121
import aws.smithy.kotlin.runtime.httptest.TestConnection
2222
import aws.smithy.kotlin.runtime.httptest.buildTestConnection
23+
import aws.smithy.kotlin.runtime.retries.StandardRetryStrategyOptions
2324
import aws.smithy.kotlin.runtime.time.Instant
2425
import aws.smithy.kotlin.runtime.time.TimestampFormat
2526
import io.kotest.matchers.string.shouldContain
@@ -57,16 +58,12 @@ class EcsCredentialsProviderTest {
5758
return HttpResponse(HttpStatusCode.OK, Headers.Empty, ByteArrayContent(payload))
5859
}
5960

60-
private fun errorResponse(): HttpResponse {
61-
val payload = """
62-
{
63-
"Code" : "TestError",
64-
"LastUpdated" : "2021-09-17T20:57:08Z",
65-
"Message": "Test error code response"
66-
}
67-
""".encodeToByteArray()
68-
return HttpResponse(HttpStatusCode.BadRequest, Headers.Empty, ByteArrayContent(payload))
69-
}
61+
private fun errorResponse(
62+
statusCode: HttpStatusCode = HttpStatusCode.BadRequest,
63+
headers: Headers = Headers.Empty,
64+
body: String = "",
65+
): HttpResponse =
66+
HttpResponse(statusCode, headers, ByteArrayContent(body.encodeToByteArray()))
7067

7168
private fun ecsRequest(url: String, authToken: String? = null): HttpRequest {
7269
val resolvedUrl = Url.parse(url)
@@ -207,7 +204,117 @@ class EcsCredentialsProviderTest {
207204
val provider = EcsCredentialsProvider(testPlatform, engine)
208205
assertFailsWith<CredentialsProviderException> {
209206
provider.getCredentials()
210-
}.message.shouldContain("Error retrieving credentials from container service: code=TestError; message=Test error code response")
207+
}.message.shouldContain("Error retrieving credentials from container service: HTTP 400: Bad Request")
208+
209+
engine.assertRequests()
210+
}
211+
212+
@Test
213+
fun testThrottledErrorResponse() = runTest {
214+
val engine = buildTestConnection {
215+
repeat(StandardRetryStrategyOptions.Default.maxAttempts) {
216+
expect(
217+
ecsRequest("http://169.254.170.2/relative"),
218+
errorResponse(statusCode = HttpStatusCode.TooManyRequests),
219+
)
220+
}
221+
}
222+
223+
val testPlatform = TestPlatformProvider(
224+
env = mapOf(AwsSdkSetting.AwsContainerCredentialsRelativeUri.environmentVariable to "/relative"),
225+
)
226+
227+
val provider = EcsCredentialsProvider(testPlatform, engine)
228+
assertFailsWith<CredentialsProviderException> {
229+
provider.getCredentials()
230+
}.message.shouldContain("Error retrieving credentials from container service: HTTP 429: Too Many Requests")
231+
232+
engine.assertRequests()
233+
}
234+
235+
@Test
236+
fun testJsonErrorResponse() = runTest {
237+
val engine = buildTestConnection {
238+
expect(
239+
ecsRequest("http://169.254.170.2/relative"),
240+
errorResponse(
241+
HttpStatusCode.BadRequest,
242+
Headers { append("Content-Type", "application/json") },
243+
"""
244+
{
245+
"Code": "failed",
246+
"Message": "request was malformed"
247+
}
248+
""",
249+
),
250+
)
251+
}
252+
253+
val testPlatform = TestPlatformProvider(
254+
env = mapOf(AwsSdkSetting.AwsContainerCredentialsRelativeUri.environmentVariable to "/relative"),
255+
)
256+
257+
val provider = EcsCredentialsProvider(testPlatform, engine)
258+
assertFailsWith<CredentialsProviderException> {
259+
provider.getCredentials()
260+
}.message.shouldContain("Error retrieving credentials from container service: code=failed; message=request was malformed")
261+
262+
engine.assertRequests()
263+
}
264+
265+
@Test
266+
fun testThrottledJsonErrorResponse() = runTest {
267+
val engine = buildTestConnection {
268+
repeat(StandardRetryStrategyOptions.Default.maxAttempts) {
269+
expect(
270+
ecsRequest("http://169.254.170.2/relative"),
271+
errorResponse(
272+
HttpStatusCode.TooManyRequests,
273+
Headers { append("Content-Type", "application/json") },
274+
"""
275+
{
276+
"Code": "failed",
277+
"Message": "too many requests"
278+
}
279+
""",
280+
),
281+
)
282+
}
283+
}
284+
285+
val testPlatform = TestPlatformProvider(
286+
env = mapOf(AwsSdkSetting.AwsContainerCredentialsRelativeUri.environmentVariable to "/relative"),
287+
)
288+
289+
val provider = EcsCredentialsProvider(testPlatform, engine)
290+
assertFailsWith<CredentialsProviderException> {
291+
provider.getCredentials()
292+
}.message.shouldContain("Error retrieving credentials from container service: code=failed; message=too many requests")
293+
294+
engine.assertRequests()
295+
}
296+
297+
@Test
298+
fun test5XXErrorResponse() = runTest {
299+
val engine = buildTestConnection {
300+
repeat(StandardRetryStrategyOptions.Default.maxAttempts) {
301+
expect(
302+
ecsRequest("http://169.254.170.2/relative"),
303+
errorResponse(
304+
HttpStatusCode.BadGateway,
305+
),
306+
)
307+
}
308+
}
309+
310+
val testPlatform = TestPlatformProvider(
311+
env = mapOf(AwsSdkSetting.AwsContainerCredentialsRelativeUri.environmentVariable to "/relative"),
312+
)
313+
314+
val provider = EcsCredentialsProvider(testPlatform, engine)
315+
assertFailsWith<CredentialsProviderException> {
316+
provider.getCredentials()
317+
}.message.shouldContain("Error retrieving credentials from container service: HTTP 502: Bad Gateway")
211318

212319
engine.assertRequests()
213320
}

0 commit comments

Comments
 (0)