Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,17 @@ The `CredentialStore` object serves as an endpoint for the retrieval of AWS cred

The `S3Bucket` class wraps an instance of `S3AsyncClient` (from AWS SDK for Java) and exposes a higher level interface for pushing and pulling files to and from a bucket.

It reads the following optional keys from the typesafe configuration, leaving the corresponding behavior of the underlying client untouched when a key is unset:

| Key | Default | Description |
| --- | --- | --- |
| `aws.s3.region` | the client's default region | The location constraint with which to create the bucket, when it doesn't exist yet. |
| `aws.s3.max-connections` | computed by the client from its target throughput | The maximum number of S3 connections that should be established during a transfer. |
| `aws.s3.max-error-retry` | the client's default retry configuration | The maximum number of retry attempts performed by the underlying client for failed retryable requests. |
| `aws.s3.retry-on-slow-down` | `true` | Whether to retry a request that S3 throttled, identified whether it is reported as a service error or as a client-side error. |

Failed operations that are worth retrying are retried waiting for an exponentially growing duration, jittered so that concurrent callers don't retry in lockstep.

### SerializableAWSCredentials

The `SerializableAWSCredentials` class provides a serializable container for AWS credentials, extending the `AwsCredentials` class (from AWS SDK for Java).
Expand Down
3 changes: 2 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ lazy val aws = module(project, "aws")
AwsSdkS3,
AwsSdkS3Transfer,
ScalaLogging,
TypesafeConfig
TypesafeConfig,
Specs2_4Core % Test
)
)

Expand Down
11 changes: 11 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,17 @@ The `CredentialStore` object serves as an endpoint for the retrieval of AWS cred

The `S3Bucket` class wraps an instance of `S3AsyncClient` (from AWS SDK for Java) and exposes a higher level interface for pushing and pulling files to and from a bucket.

It reads the following optional keys from the typesafe configuration, leaving the corresponding behavior of the underlying client untouched when a key is unset:

| Key | Default | Description |
| --- | --- | --- |
| `aws.s3.region` | the client's default region | The location constraint with which to create the bucket, when it doesn't exist yet. |
| `aws.s3.max-connections` | computed by the client from its target throughput | The maximum number of S3 connections that should be established during a transfer. |
| `aws.s3.max-error-retry` | the client's default retry configuration | The maximum number of retry attempts performed by the underlying client for failed retryable requests. |
| `aws.s3.retry-on-slow-down` | `true` | Whether to retry a request that S3 throttled, identified whether it is reported as a service error or as a client-side error. |

Failed operations that are worth retrying are retried waiting for an exponentially growing duration, jittered so that concurrent callers don't retry in lockstep.

### SerializableAWSCredentials

The `SerializableAWSCredentials` class provides a serializable container for AWS credentials, extending the `AwsCredentials` class (from AWS SDK for Java).
Expand Down
86 changes: 61 additions & 25 deletions modules/aws/src/main/scala/com/kevel/apso/aws/S3Bucket.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,26 @@ import java.io.*
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.{CompletableFuture, CompletionException, LinkedBlockingQueue, ThreadPoolExecutor, TimeUnit}

import scala.concurrent.duration.*
import scala.jdk.CollectionConverters.*
import scala.util.{Failure, Success, Try, Using}
import scala.util.{Try, Using}

import com.typesafe.config.ConfigFactory
import com.typesafe.scalalogging.LazyLogging
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider
import software.amazon.awssdk.core.ResponseInputStream
import software.amazon.awssdk.core.async.{AsyncRequestBody, AsyncResponseTransformer}
import software.amazon.awssdk.core.exception.{SdkClientException, SdkException}
import software.amazon.awssdk.core.retry.RetryUtils
import software.amazon.awssdk.regions
import software.amazon.awssdk.services.s3.S3AsyncClient
import software.amazon.awssdk.services.s3.crt.S3CrtRetryConfiguration
import software.amazon.awssdk.services.s3.model.*
import software.amazon.awssdk.transfer.s3.{S3TransferManager, model}

import com.kevel.apso.Retry
import com.kevel.apso.aws.S3Bucket.isSlowDown

/** A representation of an Amazon's S3 bucket. This class wraps an `S3AsyncClient` and provides a higher level interface
* for pushing and pulling files to and from a bucket.
*
Expand All @@ -40,6 +45,8 @@ class S3Bucket(
private[this] lazy val region = Try(config.getString(configPrefix + ".region"))
private[this] lazy val maxConnections = Try(config.getInt(configPrefix + ".max-connections"))
private[this] lazy val maxErrorRetry = Try(config.getInt(configPrefix + ".max-error-retry"))
private[this] lazy val retryOnSlowDown =
Try(config.getBoolean(configPrefix + ".retry-on-slow-down")).getOrElse(true)

@transient private[this] lazy val defaultExecutor = {
val maxPoolSize = 100
Expand Down Expand Up @@ -169,14 +176,15 @@ class S3Bucket(
* @return
* a list of objects in a bucket matching a given prefix.
*/
def getObjectsWithMatchingPrefix(prefix: String, includeDirectories: Boolean = false): Iterator[S3Object] = retry {
logger.info(s"Finding files matching prefix '$prefix'...")
def getObjectsWithMatchingPrefix(prefix: String, includeDirectories: Boolean = false): Iterator[S3Object] =
retry {
logger.info(s"Finding files matching prefix '$prefix'...")

val req = ListObjectsV2Request.builder.bucket(bucketName).prefix(sanitizeKey(prefix)).build
val objects = listObjectsV2Iterator(req).flatMap(_.contents.asScala)
val req = ListObjectsV2Request.builder.bucket(bucketName).prefix(sanitizeKey(prefix)).build
val objects = listObjectsV2Iterator(req).flatMap(_.contents.asScala)

if (includeDirectories) objects else objects.filterNot(_.key.endsWith("/"))
}.getOrElse(Iterator.empty)
if (includeDirectories) objects else objects.filterNot(_.key.endsWith("/"))
}.getOrElse(Iterator.empty)

// FIXME: If the root directory/prefix was created by the `mkdirs` method (where we create an object with 0 bytes)
// that root directory will be present in the results. Evaluate if we should filter it out since it does not
Expand Down Expand Up @@ -413,10 +421,30 @@ class S3Bucket(
stream
}

private[aws] def retry[T](f: => T, maxRetries: Int = 2): Option[T] =
Retry
.exponentialBackOff(
maxRetries = maxRetries,
base = S3Bucket.BaseBackOff,
max = Some(S3Bucket.MaxBackOff),
jitter = S3Bucket.BackOffJitter,
retryWhen = !handler(_),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handler is a partial function (it already was before this PR), but it's being called normally. It might make sense to add a safeguard here, something like: ex => !handler.applyOrElse(ex, (_: Throwable) => false), to make a "bug" fail-fast.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See 3c2395d

onRetry = (ex, delay, remaining) =>
logger.warn(s"Error during S3 operation. Retrying in ${delay.toMillis}ms ($remaining more times)", ex),
onMaxRetriesReached = ex => logger.error("Max retries reached. Aborting S3 operation", ex)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this mean that we are now logging the full exception three times? On handler, onRetry, and onMaxRetriesReached. This might be costly.

We could just log it on the handler.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See 3c2395d

)(f)
.toOption

private def log(isError: Boolean, message: String, cause: Throwable): Unit =
if (isError) logger.error(message, cause) else logger.warn(message, cause)

private[this] def handler: PartialFunction[Throwable, Boolean] = {
// Matched ahead of the shape-specific cases below, since a slow down is reported both as a service error and as a
// client-side error, depending on the client in use.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's not too much of an hassle, can we leave it a small note regarding the behavior with the CRT client? That we observed with this being not a retryable exception.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See c91a435

case ex: SdkException if isSlowDown(ex) =>
log(!retryOnSlowDown, s"S3 slow down: ${ex.getMessage}", ex)
!retryOnSlowDown

case ex: S3Exception =>
ex.statusCode() match {
case 404 =>
Expand All @@ -428,11 +456,12 @@ class S3Bucket(
case _ =>
logger.warn(
s"""|S3 service error: ${ex.getMessage}. Extended request id: ${ex.requestId}
|Message: ${ex.getMessage}""".stripMargin,
|Message: ${ex.getMessage}""".stripMargin,
ex
)
false
}

case ex: SdkClientException =>
log(!ex.retryable, s"Client Exception: ${ex.getMessage}", ex)
!ex.retryable
Expand All @@ -450,25 +479,32 @@ class S3Bucket(
false
}

private[this] def retry[T](f: => T, tries: Int = 3, sleepTime: Int = 5000): Option[T] =
if (tries == 0) {
logger.error("Max retries reached. Aborting S3 operation")
None
} else
Try(f) match {
case Success(res) => Some(res)
case Failure(e) if !handler(e) =>
if (tries > 1) {
logger.warn(s"Error during S3 operation. Retrying in ${sleepTime}ms (${tries - 1} more times)")
Thread.sleep(sleepTime)
}
retry(f, tries - 1, sleepTime)

case _ => None
}

override def equals(obj: Any): Boolean = obj match {
case b: S3Bucket => b.bucketName == bucketName
case _ => false
}
}

object S3Bucket extends LazyLogging {
private[aws] val BaseBackOff = 3.seconds
private[aws] val MaxBackOff = 30.seconds
private[aws] val BackOffJitter = 1.second

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could consider making these values also configurable, via HOCON, in the scenario we detect we might be "stuck" a long time retrying things.

I wouldn't block merging this PR because of that, as I think these new settings, with the default of two retries, don't change the status quo.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See 3c2395d

/** The error string with which the CRT-based S3 client reports a throttled request.
*
* The CRT signals throttling through its `AWS_ERROR_S3_SLOW_DOWN` error, which reaches the SDK as an
* `SdkClientException` whose message embeds only the rendered error string, and not the numeric error code.
*/
private[aws] val SlowDownErrorMessage = "Response code indicates throttling"

/** Returns whether the given exception reports S3 having throttled the request.
*
* A throttled request surfaces either as a service error, which S3 reports with a `503` status code, or as a
* client-side error, which is how the CRT-based client reports it.
*/
private[aws] def isSlowDown(ex: SdkException): Boolean =
RetryUtils.isThrottlingException(ex) || (ex match {
case ex: SdkClientException => Option(ex.getMessage).exists(_.contains(SlowDownErrorMessage))
case _ => false
})
}
59 changes: 59 additions & 0 deletions modules/aws/src/test/scala/com/kevel/apso/aws/S3BucketSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.kevel.apso.aws

import org.specs2.mutable.Specification
import software.amazon.awssdk.awscore.exception.AwsErrorDetails
import software.amazon.awssdk.core.exception.SdkClientException
import software.amazon.awssdk.services.s3.model.S3Exception

class S3BucketSpec extends Specification {

// The message the CRT-based S3 client builds for a failed request, as seen in production.
private def clientError(errorString: String) =
SdkClientException.create(s"Failed to send the request: $errorString")

private def serviceError(statusCode: Int, errorCode: String) =
S3Exception
.builder()
.statusCode(statusCode)
.awsErrorDetails(AwsErrorDetails.builder().errorCode(errorCode).serviceName("S3").build())
.message("Boom")
.build()

"An S3Bucket" should {

"recognize a slow down" in {

"reported as a service error" in {
S3Bucket.isSlowDown(serviceError(503, "SlowDown")) must beTrue
}

"reported as a service error with a 429 status code" in {
S3Bucket.isSlowDown(serviceError(429, "TooManyRequestsException")) must beTrue
}

"reported by the CRT client as a client-side error" in {
S3Bucket.isSlowDown(clientError(S3Bucket.SlowDownErrorMessage)) must beTrue
}
}

"not recognize as a slow down" in {

// A 503 alone doesn't imply throttling, so it must keep being handled as a plain service error.
"a service error with a 503 status code but no throttling error code" in {
S3Bucket.isSlowDown(serviceError(503, "ServiceUnavailable")) must beFalse
}

"a service error reporting another failure" in {
S3Bucket.isSlowDown(serviceError(404, "NoSuchKey")) must beFalse
}

"a client-side error reporting another failure" in {
S3Bucket.isSlowDown(clientError("Socket closed")) must beFalse
}

"a client-side error without a message" in {
S3Bucket.isSlowDown(SdkClientException.builder().build()) must beFalse
}
}
}
}
85 changes: 84 additions & 1 deletion modules/core/src/main/scala/com/kevel/apso/Retry.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package com.kevel.apso

import scala.annotation.tailrec
import scala.concurrent.duration.*
import scala.concurrent.{ExecutionContext, Future, blocking}
import scala.util.control.NonFatal
import scala.util.{Failure, Success, Try}
import scala.util.{Failure, Random, Success, Try}

/** Utility object with retry mechanisms.
*/
Expand Down Expand Up @@ -73,4 +74,86 @@ object Retry {
*/
def retry[T](maxRetries: Int = 10, inBetweenSleep: FiniteDuration = 100.millis)(f: => T): Try[T] =
retry(maxRetries, Option(inBetweenSleep))(f)

/** Computes the duration to wait before the next attempt, growing exponentially with the number of attempts made.
*
* @param attempt
* the zero-based index of the attempt that failed
* @param base
* the base waiting duration
* @param max
* the optional duration with which to cap the exponential growth
* @param factor
* the factor of the exponential duration
* @param jitter
* the upper bound of the random duration added to the result
* @return
* the duration to wait before the next attempt
*/
private[apso] def exponentialBackOffDelay(
attempt: Int,
base: FiniteDuration,
max: Option[FiniteDuration] = None,
factor: Double = 2.0,
jitter: FiniteDuration = 1.second
): FiniteDuration = {
val exponential = (base.toMillis * Math.pow(factor, attempt.toDouble)).toLong
val capped = max.fold(exponential)(m => Math.min(exponential, m.toMillis))
(capped + (Random.nextDouble() * jitter.toMillis).toLong).millis

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder about this jitter approach. Reading through https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/, I think we could do better.

The default is 1.0 second currently and it's add after the capped value, so, to deal with contention from multiple callers, it will kind-of evenly spread things out at a maximum of like 1sec at the maximum, so for hundreds of callers, they will fall on the same second.

There's a few different ways we can take it. If we commit to a "full jitter" implementation we can simplify this a lot and remove the jitter argument and just do something like: ThreadLocalRandom.current().nextLong(capMillis + 1).millis

However, if we want to be able to toggle the jitterness, maybe something like:

    val jitterFraction = Math.min(1.0, Math.max(0.0, jitter)) // being extra careful, lol...

    (
      (1.0 - jitterFraction) * capped +
        ThreadLocalRandom.current().nextDouble() * jitterFraction * capped
    ).toLong.millis

This seems like a better alternative, but I didn't test or benchmarks it. Thoughts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion! I hadn't thought much about the jitter since this was mostly copied over from another internal project. See 8397dc1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading the javadocs of ThreadLocalRandom leads me to think that we want to prefer it here ThreadLocalRandom.current().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, see 8397dc1

}

/** Performs a function `f` until it succeeds or until maximum retries is reached, waiting between attempts for a
* duration that grows exponentially with the number of attempts made.
*
* @param maxRetries
* the number of retries
* @param base
* the base waiting duration
* @param max
* the optional duration with which to cap the exponential growth
* @param factor
* the factor of the exponential duration
* @param jitter
* the upper bound of the random duration added to each waiting duration
* @param retryWhen
* the predicate deciding whether a failure is worth retrying
* @param onRetry
* the function called before each retry with the failure being retried, the duration that will be waited for and
* the number of retries still left
* @param onMaxRetriesReached
* the function called when the max retries are reached with the latest failure
* @param f
* the function to retry
* @return
* a Try of the `f` function result
*/
def exponentialBackOff[T](
maxRetries: Int,
base: FiniteDuration,
max: Option[FiniteDuration] = None,
factor: Double = 2.0,
jitter: FiniteDuration = 1.second,
retryWhen: Throwable => Boolean = _ => true,
onRetry: (Throwable, FiniteDuration, Int) => Unit = (_, _, _) => (),
onMaxRetriesReached: Throwable => Unit = _ => ()
)(f: => T): Try[T] = {
@tailrec
def aux(attempt: Int): Try[T] =
Try(f) match {
case res @ Success(_) => res
case failure @ Failure(ex) =>
if (!retryWhen(ex)) failure
else if (attempt >= maxRetries) {
onMaxRetriesReached(ex)
failure
} else {
val delay = exponentialBackOffDelay(attempt, base, max, factor, jitter)
onRetry(ex, delay, maxRetries - attempt)
Thread.sleep(delay.toMillis)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I came across this blog https://blog.damavis.com/en/blocking-calls-and-asynchronous-programming-with-scala/ and thought to share.

I don't think it matter that much for the current state of things, as apso is providing a "sync" API so this wouldn't have an effect on the apps using it.

@rafaavc rafaavc Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's not ideal. Since this was already the behavior of the Retry module and the API is sync, I think it should be acceptable. I added the blocking hint in 3c2395d

aux(attempt + 1)
}
}

aux(0)
}
}
Loading