
Composable, coroutine-first resilience primitives (bulkhead, circuit breaker, rate limiter, retry, cache, saga, timeouts) with DSL builders, observability, named registries, policy composition and retry-budget protection.
Coroutine-first resilience patterns for Kotlin Multiplatform, built on Arrow: retry, circuit breakers, bulkheads, rate limiting, timeouts, hedging, failover, sagas and caching. They compose, report statistics, and run unchanged on every target.
Targets: JVM 17+ (and Android), JS (browser, Node.js), Wasm, Linux, Windows, macOS, iOS. Docs: API reference · Java, Swift and JS/TS usage · Changelog
Replace <version> with the version from the badges above.
dependencies {
implementation("io.github.sorinirimies.arrow:arrow-resilience-kit:<version>")
}From Maven Central. JitPack, GitHub Packages and Maven setup: INSTALLATION.md. Calling it from Java: INTEROP.md.
.package(url: "https://github.com/sorinirimies/arrow-resilience-kit", from: "<version>")Ships a prebuilt XCFramework. Calling it from Swift: INTEROP.md.
npm install arrow-resilience-kitA Promise API with TypeScript types, ES modules and CommonJS, no dependencies. Usage: the package README.
import com.sorinirmies.arrow.resiliencekit.*
import kotlin.time.Duration.Companion.seconds
val breaker = circuitBreaker {
failureThreshold = 5
resetTimeout = 30.seconds
}
val data = retryWithBackoff(
retries = 3,
shouldRetry = { e, _ -> e is HttpException && (e.status == 429 || e.status >= 500) },
delayFor = { e, _ -> (e as? HttpException)?.retryAfter }, // honor Retry-After
) {
breaker.execute { withTimeLimit(5.seconds) { api.fetchData() } }
}| Pattern | Use it to | Entry point |
|---|---|---|
| Retry & repeat | retry transient failures, poll until done |
retryWithBackoff, retryIf, repeatUntil
|
| Retry budget | stop retries from amplifying an outage |
RetryBudget, retryWithBudget
|
| Circuit breaker | fail fast while a dependency is down |
circuitBreaker { }, slidingWindowCircuitBreaker { }
|
| Bulkhead | cap concurrent calls |
bulkhead { }, withBulkhead
|
| Rate limiter | cap throughput |
rateLimiter { }, SlidingWindowRateLimiter
|
| Adaptive limiter | find the concurrency limit from latency | AdaptiveLimiter |
| Time limiter | enforce deadlines |
timeLimiter { }, withTimeLimit
|
| Hedge | cut tail latency with a backup request | hedge |
| Failover | fall back across providers | failover { } |
| Saga | undo completed steps when a later one fails | saga { } |
| Cache | TTL cache with LRU/LFU/FIFO eviction |
cache { }, LoadingCache
|
| Composition | combine patterns, or apply them to a Flow
|
Policy, throughCircuitBreaker
|
| Testing | inject failures and latency | chaos |
Every pattern has a DSL builder with documented defaults, a statistics() snapshot, and a named registry (CircuitBreakerRegistry, BulkheadRegistry, ...) for per-endpoint or per-tenant instances.
retryWithExponentialBackoff(retries = 5, base = 100.milliseconds) { api.fetch() }
retryWithCappedBackoff(retries = 8, base = 100.milliseconds, maxDelay = 10.seconds) { api.fetch() }
retryIf(retries = 3, delay = 1.seconds, shouldRetry = { it is IOException }) { api.fetch() }
retryOrDefault(retries = 3, fallback = { emptyList() }) { api.fetchList() }
repeatUntil(maxAttempts = 10, delay = 1.seconds, condition = { it.status == "COMPLETE" }) { api.pollStatus() }Also retryWithConstantDelay, retryWithFibonacci, retryIfWithExponentialBackoff and repeatAndCollect. retryWithBackoff (see Quick start) takes all options at once: retry filter, per-error delay, cap, jitter and an onRetry hook.
One budget per downstream, shared by every caller. Retries are allowed only up to a fraction of successful traffic, so a failing service isn't hammered by everyone retrying at once.
val budget = RetryBudget.create()
retryWithBudget(budget, retries = 5) { callFlakyService() }val cb = circuitBreaker {
failureThreshold = 5 // consecutive failures before opening
resetTimeout = 60.seconds // then half-open: let trial calls through
halfOpenSuccessThreshold = 2 // successes needed to close again
}
cb.executeOrFallback(fallback = { defaultResponse }) { service.call() }
cb.addListener { from, to -> log.info("circuit $from -> $to") }
// Trips on a failure rate instead: at least 50 % of the last 20 calls
val rate = slidingWindowCircuitBreaker {
slidingWindowSize = 20
minimumNumberOfCalls = 10
failureRateThreshold = 0.5
}val bh = bulkhead {
maxConcurrentCalls = 10
maxWaitingCalls = 20
maxWaitDuration = 5.seconds
}
bh.execute { dbPool.query() }DispatcherBulkhead runs admitted calls on a dedicated dispatcher; ThreadPoolBulkhead (JVM) owns a thread pool.
val rl = rateLimiter { permitsPerSecond = 10.0; burstCapacity = 20 } // token bucket
rl.execute { api.request() }
val window = SlidingWindowRateLimiter(SlidingWindowConfig(maxRequests = 100, windowDuration = 1.seconds))Additive-increase, multiplicative-decrease, like TCP congestion control: the limit grows while calls succeed quickly and shrinks on failures or slow calls.
val limiter = AdaptiveLimiter.create(AdaptiveLimiterConfig(initialLimit = 20, latencyThreshold = 200.milliseconds))
limiter.execute { downstream.call() }val tl = timeLimiter { timeout = 5.seconds }
tl.executeOrNull { slowOp() }
tl.executeOrFallback(fallback = { cachedValue }) { slowOp() }
tl.executeWithRetry(retries = 3) { slowOp() }Starts another attempt if the first is slow; the first success wins. Only for calls that are safe to run twice.
hedge(hedgeDelay = 50.milliseconds, maxHedges = 2) { api.fetchData() }Each provider has its own circuit breaker: failing providers are skipped and re-tried after their reset timeout, so traffic returns to the preferred one once it recovers.
val transport = failover<Connection> {
provider("websocket") { connectWebSocket() }
provider("mqtt") { connectMqtt() }
provider("http-polling") { connectHttpPolling() }
}
val connection = transport.execute() // FailoverExhaustedException if all are down
transport.startHealthProbing(scope, interval = 30.seconds) // re-check skipped providers without real trafficval order = saga<OrderResult> {
step("Reserve inventory", action = { inventory.reserve(items) }, compensation = { inventory.release(it.id) })
step("Charge payment", action = { payments.charge(amount) }, compensation = { payments.refund(it.txId) })
stepWithRetry("Send confirmation", retries = 3, action = { email.send(confirmation) })
}
when (val result = order.execute()) {
is SagaResult.Success -> println(result.result)
is SagaResult.Failure -> println("failed, ${result.compensatedSteps} steps undone: ${result.error}")
}val users = cache<String, User> { maxSize = 1_000; ttl = 5.minutes; evictionStrategy = EvictionStrategy.LRU }
users.getOrPut("user-123") { userService.fetch("user-123") }
val loader = LoadingCache.create<String, User>(CacheConfig(maxSize = 500, ttl = 10.minutes)) { id -> userService.fetch(id) }
loader.get("user-456")a + b means a wraps b.
val policy = retryPolicy(retries = 3) + circuitBreaker.asPolicy() + bulkhead.asPolicy()
policy.apply { api.fetchData() }
api.streamUpdates()
.throughCircuitBreaker(circuitBreaker)
.retryWithBackoff(retries = 3)
.collect { println(it) }val flaky = chaos({ failureRate = 0.3; latency = 50.milliseconds }) { realApi.call() }
retryWithExponentialBackoff(retries = 5) { flaky() } // does your retry logic hold up?Also included: lock-free STM primitives (StmCounter, StmSemaphore, StmStateMachine, ...) built on Arrow STM.
All optional and compileOnly: add the library you use to your own build.
MicrometerBridge.bindCircuitBreaker(registry, "orders", cb) exports statistics as gauges (also bulkhead, rate limiter, adaptive limiter, cache, time limiter, failover).install(ArrowResilience) { policy = ... }; rejections map to 429, 503 and 504.arrow.resilience.circuit-breaker.<name>.* (and bulkhead, rate-limiter, time-limiter) properties create a ResilienceInstances bean.DistributedCircuitBreaker and DistributedRateLimiter share state across instances via RedisSharedStateStore (Lettuce), or any SharedStateStore.ResilienceConfig.fromJson(...) or fromProperties(...), then ResilienceInstances.create(...).just test # JVM tests (just test-all: every platform)
just check-all # quality gate: detekt, build, JVM + JS tests, npm package smoke test
just release 1.2.3Commits follow Conventional Commits; the changelog is generated with git-cliff. Dependencies are upgraded nightly within their major version. See CONTRIBUTING.md and RELEASING.md.
Coroutine-first resilience patterns for Kotlin Multiplatform, built on Arrow: retry, circuit breakers, bulkheads, rate limiting, timeouts, hedging, failover, sagas and caching. They compose, report statistics, and run unchanged on every target.
Targets: JVM 17+ (and Android), JS (browser, Node.js), Wasm, Linux, Windows, macOS, iOS. Docs: API reference · Java, Swift and JS/TS usage · Changelog
Replace <version> with the version from the badges above.
dependencies {
implementation("io.github.sorinirimies.arrow:arrow-resilience-kit:<version>")
}From Maven Central. JitPack, GitHub Packages and Maven setup: INSTALLATION.md. Calling it from Java: INTEROP.md.
.package(url: "https://github.com/sorinirimies/arrow-resilience-kit", from: "<version>")Ships a prebuilt XCFramework. Calling it from Swift: INTEROP.md.
npm install arrow-resilience-kitA Promise API with TypeScript types, ES modules and CommonJS, no dependencies. Usage: the package README.
import com.sorinirmies.arrow.resiliencekit.*
import kotlin.time.Duration.Companion.seconds
val breaker = circuitBreaker {
failureThreshold = 5
resetTimeout = 30.seconds
}
val data = retryWithBackoff(
retries = 3,
shouldRetry = { e, _ -> e is HttpException && (e.status == 429 || e.status >= 500) },
delayFor = { e, _ -> (e as? HttpException)?.retryAfter }, // honor Retry-After
) {
breaker.execute { withTimeLimit(5.seconds) { api.fetchData() } }
}| Pattern | Use it to | Entry point |
|---|---|---|
| Retry & repeat | retry transient failures, poll until done |
retryWithBackoff, retryIf, repeatUntil
|
| Retry budget | stop retries from amplifying an outage |
RetryBudget, retryWithBudget
|
| Circuit breaker | fail fast while a dependency is down |
circuitBreaker { }, slidingWindowCircuitBreaker { }
|
| Bulkhead | cap concurrent calls |
bulkhead { }, withBulkhead
|
| Rate limiter | cap throughput |
rateLimiter { }, SlidingWindowRateLimiter
|
| Adaptive limiter | find the concurrency limit from latency | AdaptiveLimiter |
| Time limiter | enforce deadlines |
timeLimiter { }, withTimeLimit
|
| Hedge | cut tail latency with a backup request | hedge |
| Failover | fall back across providers | failover { } |
| Saga | undo completed steps when a later one fails | saga { } |
| Cache | TTL cache with LRU/LFU/FIFO eviction |
cache { }, LoadingCache
|
| Composition | combine patterns, or apply them to a Flow
|
Policy, throughCircuitBreaker
|
| Testing | inject failures and latency | chaos |
Every pattern has a DSL builder with documented defaults, a statistics() snapshot, and a named registry (CircuitBreakerRegistry, BulkheadRegistry, ...) for per-endpoint or per-tenant instances.
retryWithExponentialBackoff(retries = 5, base = 100.milliseconds) { api.fetch() }
retryWithCappedBackoff(retries = 8, base = 100.milliseconds, maxDelay = 10.seconds) { api.fetch() }
retryIf(retries = 3, delay = 1.seconds, shouldRetry = { it is IOException }) { api.fetch() }
retryOrDefault(retries = 3, fallback = { emptyList() }) { api.fetchList() }
repeatUntil(maxAttempts = 10, delay = 1.seconds, condition = { it.status == "COMPLETE" }) { api.pollStatus() }Also retryWithConstantDelay, retryWithFibonacci, retryIfWithExponentialBackoff and repeatAndCollect. retryWithBackoff (see Quick start) takes all options at once: retry filter, per-error delay, cap, jitter and an onRetry hook.
One budget per downstream, shared by every caller. Retries are allowed only up to a fraction of successful traffic, so a failing service isn't hammered by everyone retrying at once.
val budget = RetryBudget.create()
retryWithBudget(budget, retries = 5) { callFlakyService() }val cb = circuitBreaker {
failureThreshold = 5 // consecutive failures before opening
resetTimeout = 60.seconds // then half-open: let trial calls through
halfOpenSuccessThreshold = 2 // successes needed to close again
}
cb.executeOrFallback(fallback = { defaultResponse }) { service.call() }
cb.addListener { from, to -> log.info("circuit $from -> $to") }
// Trips on a failure rate instead: at least 50 % of the last 20 calls
val rate = slidingWindowCircuitBreaker {
slidingWindowSize = 20
minimumNumberOfCalls = 10
failureRateThreshold = 0.5
}val bh = bulkhead {
maxConcurrentCalls = 10
maxWaitingCalls = 20
maxWaitDuration = 5.seconds
}
bh.execute { dbPool.query() }DispatcherBulkhead runs admitted calls on a dedicated dispatcher; ThreadPoolBulkhead (JVM) owns a thread pool.
val rl = rateLimiter { permitsPerSecond = 10.0; burstCapacity = 20 } // token bucket
rl.execute { api.request() }
val window = SlidingWindowRateLimiter(SlidingWindowConfig(maxRequests = 100, windowDuration = 1.seconds))Additive-increase, multiplicative-decrease, like TCP congestion control: the limit grows while calls succeed quickly and shrinks on failures or slow calls.
val limiter = AdaptiveLimiter.create(AdaptiveLimiterConfig(initialLimit = 20, latencyThreshold = 200.milliseconds))
limiter.execute { downstream.call() }val tl = timeLimiter { timeout = 5.seconds }
tl.executeOrNull { slowOp() }
tl.executeOrFallback(fallback = { cachedValue }) { slowOp() }
tl.executeWithRetry(retries = 3) { slowOp() }Starts another attempt if the first is slow; the first success wins. Only for calls that are safe to run twice.
hedge(hedgeDelay = 50.milliseconds, maxHedges = 2) { api.fetchData() }Each provider has its own circuit breaker: failing providers are skipped and re-tried after their reset timeout, so traffic returns to the preferred one once it recovers.
val transport = failover<Connection> {
provider("websocket") { connectWebSocket() }
provider("mqtt") { connectMqtt() }
provider("http-polling") { connectHttpPolling() }
}
val connection = transport.execute() // FailoverExhaustedException if all are down
transport.startHealthProbing(scope, interval = 30.seconds) // re-check skipped providers without real trafficval order = saga<OrderResult> {
step("Reserve inventory", action = { inventory.reserve(items) }, compensation = { inventory.release(it.id) })
step("Charge payment", action = { payments.charge(amount) }, compensation = { payments.refund(it.txId) })
stepWithRetry("Send confirmation", retries = 3, action = { email.send(confirmation) })
}
when (val result = order.execute()) {
is SagaResult.Success -> println(result.result)
is SagaResult.Failure -> println("failed, ${result.compensatedSteps} steps undone: ${result.error}")
}val users = cache<String, User> { maxSize = 1_000; ttl = 5.minutes; evictionStrategy = EvictionStrategy.LRU }
users.getOrPut("user-123") { userService.fetch("user-123") }
val loader = LoadingCache.create<String, User>(CacheConfig(maxSize = 500, ttl = 10.minutes)) { id -> userService.fetch(id) }
loader.get("user-456")a + b means a wraps b.
val policy = retryPolicy(retries = 3) + circuitBreaker.asPolicy() + bulkhead.asPolicy()
policy.apply { api.fetchData() }
api.streamUpdates()
.throughCircuitBreaker(circuitBreaker)
.retryWithBackoff(retries = 3)
.collect { println(it) }val flaky = chaos({ failureRate = 0.3; latency = 50.milliseconds }) { realApi.call() }
retryWithExponentialBackoff(retries = 5) { flaky() } // does your retry logic hold up?Also included: lock-free STM primitives (StmCounter, StmSemaphore, StmStateMachine, ...) built on Arrow STM.
All optional and compileOnly: add the library you use to your own build.
MicrometerBridge.bindCircuitBreaker(registry, "orders", cb) exports statistics as gauges (also bulkhead, rate limiter, adaptive limiter, cache, time limiter, failover).install(ArrowResilience) { policy = ... }; rejections map to 429, 503 and 504.arrow.resilience.circuit-breaker.<name>.* (and bulkhead, rate-limiter, time-limiter) properties create a ResilienceInstances bean.DistributedCircuitBreaker and DistributedRateLimiter share state across instances via RedisSharedStateStore (Lettuce), or any SharedStateStore.ResilienceConfig.fromJson(...) or fromProperties(...), then ResilienceInstances.create(...).just test # JVM tests (just test-all: every platform)
just check-all # quality gate: detekt, build, JVM + JS tests, npm package smoke test
just release 1.2.3Commits follow Conventional Commits; the changelog is generated with git-cliff. Dependencies are upgraded nightly within their major version. See CONTRIBUTING.md and RELEASING.md.