
Lightweight retry toolkit offering configurable retry policies, backoff strategies (exponential, decorrelated), multiple jitter modes, coroutine and blocking APIs, attempt context, and minimal runtime footprint.
A lightweight Kotlin Multiplatform retry library with coroutine and blocking APIs.
RetryKt provides a retry model across Kotlin platforms with retry policies, configurable backoff strategies, jitter, and a minimal runtime footprint.
RetryKt intentionally focuses on reliable retries instead of providing a complete resilience framework.
val user = retry {
api.getUser()
}
val response = retry(
retryOn = RetryOn.thrown { it is IOException },
backoff = ExponentialBackoff(200.milliseconds),
jitter = FullJitter,
) {
api.removeUser(user)
}repeat(3) is fine until retries need real rules. Most apps eventually need to:
RetryKt provides these capabilities in a small, focused library without framework-specific dependencies.
Instead of writing ad-hoc retry loops, you define what should be retried (RetryOn) and how retries are
scheduled (Backoff + Jitter).
dependencies {
implementation("io.github.straxess:retrykt:<version>")
}<dependency>
<groupId>io.github.straxess</groupId>
<artifactId>retrykt</artifactId>
<version>...</version>
</dependency>These are the Kotlin and Coroutines versions used to test this release line.
| RetryKt Version | Kotlin Version | Kotlin Coroutines Version |
|---|---|---|
| 0.2.x | 2.3.x | 1.10.x |
The JVM artifact targets Java 11. It is built with JDK 17.
Rule of thumb
Use
retry()in suspend code.
UseretryBlocking()everywhere else.
val user = retry {
api.getUser()
}val user = retry(
maxAttempts = 5,
backoff = ExponentialBackoff(
initialDelay = 100.milliseconds,
multiplier = 2.0,
maxDelay = 10.seconds,
),
) {
api.getUser()
}Jitter is a separate step after backoff, so you can mix and match both.
val response = retry(
backoff = ExponentialBackoff(
initialDelay = 200.milliseconds,
maxDelay = 10.seconds,
),
jitter = FullJitter,
) {
api.getResponse()
}val user = retry(
retryOn = RetryOn.thrown { it is IOException },
) {
api.getUser()
}Sometimes an operation succeeds but returns a value that should be retried.
val response = retry(
retryOn = RetryOn.returned { it.status == 503 },
) {
api.getResponse()
}Each attempt gets a RetryContext.
retry(maxAttempts = 3) { ctx ->
log.info("Attempt ${ctx.attempt}/${ctx.maxAttempts}")
uploadFile()
}onRetryAttempt runs after the next delay is calculated and just before RetryKt waits.
retry(
onRetryAttempt = { event ->
log.info("Attempt ${event.context.attempt} failed. Retrying in ${event.plan.nextDelay}.")
},
) {
fetchData()
}RetryOn decides whether the last result deserves another attempt. It can inspect both thrown exceptions and returned
values.
By default, RetryKt retries exceptions and accepts returned values. Kotlin Error subclasses pass through. Only retry
an Error with an explicit RetryOn policy, and only if you really mean it.
For example, retry only network failures:
retry(retryOn = RetryOn.thrown { it is IOException || it is TimeoutException }) {
request()
}Some APIs report temporary failures through return values rather than exceptions.
retry(retryOn = RetryOn.returned { it.status == 503 }) {
api.getResponse()
}Need both the value and the exception? Use outcome:
retry(
retryOn = RetryOn.outcome { outcome ->
when (outcome) {
is AttemptOutcome.Returned -> outcome.value.shouldRetry()
is AttemptOutcome.Thrown -> outcome.throwable is IOException
}
},
) {
request()
}AttemptOutcome is the single type used for both cases.
A backoff calculates the base delay before the next attempt.
Backoff and jitter are separate concepts:
Backoff
↓
raw delay
↓
Jitter
↓
actual delay (applied delay)
↓
wait
This separation allows the same backoff strategy to be combined with different jitter strategies.
Built-in backoff implementations include:
NoBackoff // 0ms
ConstantBackoff // 100ms, 100ms, 100ms
LinearBackoff // 100ms, 200ms, 300ms
ExponentialBackoff // 100ms, 200ms, 400ms
DecorrelatedBackoff // randomized, based on the previous applied delayChoose the strategy that matches your workload.
| Strategy | Typical use case |
|---|---|
NoBackoff |
Tests, CPU-bound operations |
ConstantBackoff |
Fixed polling intervals |
LinearBackoff |
Gradually increasing retry intervals |
ExponentialBackoff |
Network requests, cloud APIs, distributed systems |
DecorrelatedBackoff |
Distributed systems where randomized, decorrelated delays are desirable |
ExponentialBackoff(
initialDelay = 100.milliseconds,
multiplier = 2.0,
maxDelay = 10.seconds,
)DecorrelatedBackoff is the AWS-style decorrelated-jitter algorithm packaged as a backoff. It uses the actual delay
from the previous retry when calculating the next one.
DecorrelatedBackoff(
initialDelay = 100.milliseconds,
maxDelay = 10.seconds,
)It already randomizes delays, so pair it with NoJitter unless you deliberately want more randomness.
Custom backoffs receive the current attempt and the actual delay used before it.
class MyBackoff : Backoff {
override fun nextDelay(context: BackoffContext): Duration {
val attempt = context.attempt
val lastAppliedDelay = context.lastAppliedDelay
// ...
}
}retry(backoff = MyBackoff()) {
task()
}lastAppliedDelay is null for the first retry attempt.
Jitter changes the delay from backoff. It helps keep many clients from retrying at the same time.
RetryKt applies jitter after backoff:
rawDelay = backoff.nextDelay(...)
appliedDelay = jitter.apply(rawDelay)
Built-in jitter strategies:
| Strategy | Behavior |
|---|---|
NoJitter |
Leaves the backoff delay unchanged |
FullJitter |
Random delay in [0, rawDelay)
|
EqualJitter |
Keeps half of the raw delay and randomizes the other half |
AdditiveJitter |
Adds an independent random delay in [0, maxJitter)
|
Full Jitter picks a random delay between zero and the backoff delay.
retry(
backoff = ExponentialBackoff(
initialDelay = 200.milliseconds,
maxDelay = 10.seconds,
),
jitter = FullJitter,
) {
request()
}Conceptually:
appliedDelay = random(0, rawDelay)
Equal Jitter keeps half the delay, then randomizes the rest.
temp = rawDelay
appliedDelay = temp / 2 + random(0, temp / 2)
retry(
backoff = ExponentialBackoff(200.milliseconds),
jitter = EqualJitter,
) {
request()
}AdditiveJitter adds an independent random delay.
retry(
backoff = ExponentialBackoff(200.milliseconds),
jitter = AdditiveJitter(100.milliseconds),
) {
request()
}For a raw delay of 200ms, the resulting delay is in the range:
[200ms, 300ms)
Unlike FullJitter and EqualJitter, its random part does not depend on the backoff delay.
Jitter is a functional interface, so custom strategies stay small.
class MyJitter : Jitter {
override fun apply(rawDelay: Duration): Duration {
// ...
}
}Or using a lambda:
retry(jitter = { rawDelay -> /* ... */ }) {
task()
}RetryKt does retries and leaves the rest to other tools.
It does not try to provide:
Use dedicated libraries when you need those pieces.
Use retry() from suspend code.
val user = retry(
retryOn = RetryOn.thrown { it is IOException },
backoff = ExponentialBackoff(200.milliseconds),
jitter = FullJitter,
) {
client.get("/users/$id").body<User>()
}class UserRepository(
private val api: UserApi,
) {
suspend fun getUser(id: Long): User {
return retry(backoff = LinearBackoff(200.milliseconds)) {
api.getUser(id)
}
}
}Use retryBlocking() when the calling code is synchronous.
On JavaScript and WebAssembly, retryBlocking() only supports zero-delay retries. A positive delay throws
UnsupportedOperationException: those platforms cannot block the current thread.
val cache = Caffeine.newBuilder()
.build<String, User> { id ->
retryBlocking {
api.loadUser(id)
}
}Kotlin/Native callbacks often come from C libraries. Those callbacks cannot be suspend, so retryBlocking() fits well
here.
Common examples:
// Simplified example
val callback = staticCFunction { chunk ->
retryBlocking(
retryOn = RetryOn.thrown { it is IOException },
backoff = ExponentialBackoff(100.milliseconds),
jitter = FullJitter,
) {
uploader.send(chunk)
}
}class SyncWorker(
context: Context,
params: WorkerParameters,
) : Worker(context, params) {
override fun doWork(): Result {
retryBlocking {
uploadPendingFiles()
}
return Result.success()
}
}Typical use cases:
| Platform | Examples |
|---|---|
| JVM | JDBC, cache loaders, blocking HTTP clients |
| Android | WorkManager, Binder services |
| Kotlin/Native | C callbacks, POSIX APIs |
| Desktop / CLI | File I/O, external processes |
CancellationException is never retried.
When a coroutine is canceled, RetryKt stops right away: it does not call the retry policy or schedule another attempt.
retryBlocking() follows the same rule when it sees a CancellationException.
Kotlin has suspend and blocking execution models. RetryKt gives each one its own API but keeps the retry rules the same.
Yes. Use RetryOn.returned or RetryOn.outcome.
Yes. Implement Backoff and pass it to retry() or retryBlocking(). BackoffContext gives you the attempt number
and the actual delay from the previous retry.
Yes. Implement Jitter and pass it independently of backoff.
val jitter = Jitter { rawDelay ->
rawDelay * Random.nextDouble()
}Backoff chooses the base delay. Jitter changes that delay, usually with randomness.
For example:
ExponentialBackoff
↓
100ms → 200ms → 400ms
↓
FullJitter
↓
random(0, 100)ms → random(0, 200)ms → random(0, 400)ms
They are separate so you can combine them freely.
DecorrelatedBackoff is the AWS decorrelated-jitter algorithm. Its next delay depends on the actual previous delay and
already includes randomness, so it normally uses NoJitter.
Yes. See Supported Platforms.
Flow.retryWhen() is for Flows only. RetryKt works with any suspend or blocking operation and lets you configure
policies, backoff, jitter, and callbacks.
RetryKt currently supports:
| Platform | Supported |
|---|---|
| JVM | ✅ |
| Android | ✅ |
| iOS (x64) | ✅ |
| iOS (ARM64) | ✅ |
| iOS Simulator (Apple Silicon) | ✅ |
| macOS (Apple Silicon) | ✅ |
| Windows | ✅ |
| Linux x64 | ✅ |
| Linux ARM64 | ✅ |
| JavaScript | ✅ |
| WebAssembly | ✅ |
Apache License 2.0.
A lightweight Kotlin Multiplatform retry library with coroutine and blocking APIs.
RetryKt provides a retry model across Kotlin platforms with retry policies, configurable backoff strategies, jitter, and a minimal runtime footprint.
RetryKt intentionally focuses on reliable retries instead of providing a complete resilience framework.
val user = retry {
api.getUser()
}
val response = retry(
retryOn = RetryOn.thrown { it is IOException },
backoff = ExponentialBackoff(200.milliseconds),
jitter = FullJitter,
) {
api.removeUser(user)
}repeat(3) is fine until retries need real rules. Most apps eventually need to:
RetryKt provides these capabilities in a small, focused library without framework-specific dependencies.
Instead of writing ad-hoc retry loops, you define what should be retried (RetryOn) and how retries are
scheduled (Backoff + Jitter).
dependencies {
implementation("io.github.straxess:retrykt:<version>")
}<dependency>
<groupId>io.github.straxess</groupId>
<artifactId>retrykt</artifactId>
<version>...</version>
</dependency>These are the Kotlin and Coroutines versions used to test this release line.
| RetryKt Version | Kotlin Version | Kotlin Coroutines Version |
|---|---|---|
| 0.2.x | 2.3.x | 1.10.x |
The JVM artifact targets Java 11. It is built with JDK 17.
Rule of thumb
Use
retry()in suspend code.
UseretryBlocking()everywhere else.
val user = retry {
api.getUser()
}val user = retry(
maxAttempts = 5,
backoff = ExponentialBackoff(
initialDelay = 100.milliseconds,
multiplier = 2.0,
maxDelay = 10.seconds,
),
) {
api.getUser()
}Jitter is a separate step after backoff, so you can mix and match both.
val response = retry(
backoff = ExponentialBackoff(
initialDelay = 200.milliseconds,
maxDelay = 10.seconds,
),
jitter = FullJitter,
) {
api.getResponse()
}val user = retry(
retryOn = RetryOn.thrown { it is IOException },
) {
api.getUser()
}Sometimes an operation succeeds but returns a value that should be retried.
val response = retry(
retryOn = RetryOn.returned { it.status == 503 },
) {
api.getResponse()
}Each attempt gets a RetryContext.
retry(maxAttempts = 3) { ctx ->
log.info("Attempt ${ctx.attempt}/${ctx.maxAttempts}")
uploadFile()
}onRetryAttempt runs after the next delay is calculated and just before RetryKt waits.
retry(
onRetryAttempt = { event ->
log.info("Attempt ${event.context.attempt} failed. Retrying in ${event.plan.nextDelay}.")
},
) {
fetchData()
}RetryOn decides whether the last result deserves another attempt. It can inspect both thrown exceptions and returned
values.
By default, RetryKt retries exceptions and accepts returned values. Kotlin Error subclasses pass through. Only retry
an Error with an explicit RetryOn policy, and only if you really mean it.
For example, retry only network failures:
retry(retryOn = RetryOn.thrown { it is IOException || it is TimeoutException }) {
request()
}Some APIs report temporary failures through return values rather than exceptions.
retry(retryOn = RetryOn.returned { it.status == 503 }) {
api.getResponse()
}Need both the value and the exception? Use outcome:
retry(
retryOn = RetryOn.outcome { outcome ->
when (outcome) {
is AttemptOutcome.Returned -> outcome.value.shouldRetry()
is AttemptOutcome.Thrown -> outcome.throwable is IOException
}
},
) {
request()
}AttemptOutcome is the single type used for both cases.
A backoff calculates the base delay before the next attempt.
Backoff and jitter are separate concepts:
Backoff
↓
raw delay
↓
Jitter
↓
actual delay (applied delay)
↓
wait
This separation allows the same backoff strategy to be combined with different jitter strategies.
Built-in backoff implementations include:
NoBackoff // 0ms
ConstantBackoff // 100ms, 100ms, 100ms
LinearBackoff // 100ms, 200ms, 300ms
ExponentialBackoff // 100ms, 200ms, 400ms
DecorrelatedBackoff // randomized, based on the previous applied delayChoose the strategy that matches your workload.
| Strategy | Typical use case |
|---|---|
NoBackoff |
Tests, CPU-bound operations |
ConstantBackoff |
Fixed polling intervals |
LinearBackoff |
Gradually increasing retry intervals |
ExponentialBackoff |
Network requests, cloud APIs, distributed systems |
DecorrelatedBackoff |
Distributed systems where randomized, decorrelated delays are desirable |
ExponentialBackoff(
initialDelay = 100.milliseconds,
multiplier = 2.0,
maxDelay = 10.seconds,
)DecorrelatedBackoff is the AWS-style decorrelated-jitter algorithm packaged as a backoff. It uses the actual delay
from the previous retry when calculating the next one.
DecorrelatedBackoff(
initialDelay = 100.milliseconds,
maxDelay = 10.seconds,
)It already randomizes delays, so pair it with NoJitter unless you deliberately want more randomness.
Custom backoffs receive the current attempt and the actual delay used before it.
class MyBackoff : Backoff {
override fun nextDelay(context: BackoffContext): Duration {
val attempt = context.attempt
val lastAppliedDelay = context.lastAppliedDelay
// ...
}
}retry(backoff = MyBackoff()) {
task()
}lastAppliedDelay is null for the first retry attempt.
Jitter changes the delay from backoff. It helps keep many clients from retrying at the same time.
RetryKt applies jitter after backoff:
rawDelay = backoff.nextDelay(...)
appliedDelay = jitter.apply(rawDelay)
Built-in jitter strategies:
| Strategy | Behavior |
|---|---|
NoJitter |
Leaves the backoff delay unchanged |
FullJitter |
Random delay in [0, rawDelay)
|
EqualJitter |
Keeps half of the raw delay and randomizes the other half |
AdditiveJitter |
Adds an independent random delay in [0, maxJitter)
|
Full Jitter picks a random delay between zero and the backoff delay.
retry(
backoff = ExponentialBackoff(
initialDelay = 200.milliseconds,
maxDelay = 10.seconds,
),
jitter = FullJitter,
) {
request()
}Conceptually:
appliedDelay = random(0, rawDelay)
Equal Jitter keeps half the delay, then randomizes the rest.
temp = rawDelay
appliedDelay = temp / 2 + random(0, temp / 2)
retry(
backoff = ExponentialBackoff(200.milliseconds),
jitter = EqualJitter,
) {
request()
}AdditiveJitter adds an independent random delay.
retry(
backoff = ExponentialBackoff(200.milliseconds),
jitter = AdditiveJitter(100.milliseconds),
) {
request()
}For a raw delay of 200ms, the resulting delay is in the range:
[200ms, 300ms)
Unlike FullJitter and EqualJitter, its random part does not depend on the backoff delay.
Jitter is a functional interface, so custom strategies stay small.
class MyJitter : Jitter {
override fun apply(rawDelay: Duration): Duration {
// ...
}
}Or using a lambda:
retry(jitter = { rawDelay -> /* ... */ }) {
task()
}RetryKt does retries and leaves the rest to other tools.
It does not try to provide:
Use dedicated libraries when you need those pieces.
Use retry() from suspend code.
val user = retry(
retryOn = RetryOn.thrown { it is IOException },
backoff = ExponentialBackoff(200.milliseconds),
jitter = FullJitter,
) {
client.get("/users/$id").body<User>()
}class UserRepository(
private val api: UserApi,
) {
suspend fun getUser(id: Long): User {
return retry(backoff = LinearBackoff(200.milliseconds)) {
api.getUser(id)
}
}
}Use retryBlocking() when the calling code is synchronous.
On JavaScript and WebAssembly, retryBlocking() only supports zero-delay retries. A positive delay throws
UnsupportedOperationException: those platforms cannot block the current thread.
val cache = Caffeine.newBuilder()
.build<String, User> { id ->
retryBlocking {
api.loadUser(id)
}
}Kotlin/Native callbacks often come from C libraries. Those callbacks cannot be suspend, so retryBlocking() fits well
here.
Common examples:
// Simplified example
val callback = staticCFunction { chunk ->
retryBlocking(
retryOn = RetryOn.thrown { it is IOException },
backoff = ExponentialBackoff(100.milliseconds),
jitter = FullJitter,
) {
uploader.send(chunk)
}
}class SyncWorker(
context: Context,
params: WorkerParameters,
) : Worker(context, params) {
override fun doWork(): Result {
retryBlocking {
uploadPendingFiles()
}
return Result.success()
}
}Typical use cases:
| Platform | Examples |
|---|---|
| JVM | JDBC, cache loaders, blocking HTTP clients |
| Android | WorkManager, Binder services |
| Kotlin/Native | C callbacks, POSIX APIs |
| Desktop / CLI | File I/O, external processes |
CancellationException is never retried.
When a coroutine is canceled, RetryKt stops right away: it does not call the retry policy or schedule another attempt.
retryBlocking() follows the same rule when it sees a CancellationException.
Kotlin has suspend and blocking execution models. RetryKt gives each one its own API but keeps the retry rules the same.
Yes. Use RetryOn.returned or RetryOn.outcome.
Yes. Implement Backoff and pass it to retry() or retryBlocking(). BackoffContext gives you the attempt number
and the actual delay from the previous retry.
Yes. Implement Jitter and pass it independently of backoff.
val jitter = Jitter { rawDelay ->
rawDelay * Random.nextDouble()
}Backoff chooses the base delay. Jitter changes that delay, usually with randomness.
For example:
ExponentialBackoff
↓
100ms → 200ms → 400ms
↓
FullJitter
↓
random(0, 100)ms → random(0, 200)ms → random(0, 400)ms
They are separate so you can combine them freely.
DecorrelatedBackoff is the AWS decorrelated-jitter algorithm. Its next delay depends on the actual previous delay and
already includes randomness, so it normally uses NoJitter.
Yes. See Supported Platforms.
Flow.retryWhen() is for Flows only. RetryKt works with any suspend or blocking operation and lets you configure
policies, backoff, jitter, and callbacks.
RetryKt currently supports:
| Platform | Supported |
|---|---|
| JVM | ✅ |
| Android | ✅ |
| iOS (x64) | ✅ |
| iOS (ARM64) | ✅ |
| iOS Simulator (Apple Silicon) | ✅ |
| macOS (Apple Silicon) | ✅ |
| Windows | ✅ |
| Linux x64 | ✅ |
| Linux ARM64 | ✅ |
| JavaScript | ✅ |
| WebAssembly | ✅ |
Apache License 2.0.