Resilience
Module for building a fault-tolerant application using mechanisms such as CircuitBreaker, Retry, Timeout, RateLimiter and Fallback.
Every mechanism except Fallback is described by a specification interface: a typed contract that
points at a configuration path. The annotation on the protected method references that interface, so the binding between
a method and its resilience settings is checked by the compiler rather than by a string.
ResilientModule combines CircuitBreakerModule, RetryModule, TimeoutModule, FallbackModule and RateLimiterModule.
For a step-by-step walkthrough before the reference details, see Resilience.
Dependency¶
Dependency build.gradle:
Module:
Dependency build.gradle.kts:
Module:
The annotation processor (annotation-processors) or the KSP processor (symbol-processors) is required: it both
generates the specification implementations and applies the aspects.
Specifications¶
A specification is an interface that extends a resilience contract and carries the annotation with the configuration path:
| Method annotation | Specification annotation | Contract the interface extends | Package |
|---|---|---|---|
@CircuitBreakable |
@CircuitBreakerSpec |
CircuitBreaker |
io.koraframework.resilient.circuitbreaker |
@Retryable |
@RetrySpec |
Retry |
io.koraframework.resilient.retry |
@Timeout |
@TimeoutSpec |
Timeouter |
io.koraframework.resilient.timeout |
@RateLimited |
@RateLimiterSpec |
RateLimiter |
io.koraframework.resilient.ratelimiter |
@Fallback |
— | — | io.koraframework.resilient.fallback.annotation |
The method annotations live in the annotation sub-package of each contract package, for example
io.koraframework.resilient.circuitbreaker.annotation.CircuitBreakable.
@CircuitBreakerSpec("resilient.circuitbreaker.pet") //(1)!
public interface PetCircuitBreaker extends CircuitBreaker { }
@RetrySpec("resilient.retry.pet")
public interface PetRetry extends Retry { }
@TimeoutSpec("resilient.timeout.pet")
public interface PetTimeouter extends Timeouter { }
@Component
public class PetService {
@CircuitBreakable(PetCircuitBreaker.class) //(2)!
@Retryable(PetRetry.class)
@Timeout(PetTimeouter.class)
public Optional<Pet> findById(long id) {
return petRepository.findById(id);
}
}
- Full path of the configuration section that describes this instance.
- The aspect is bound to the specification type, not to a string name.
@CircuitBreakerSpec("resilient.circuitbreaker.pet") //(1)!
interface PetCircuitBreaker : CircuitBreaker
@RetrySpec("resilient.retry.pet")
interface PetRetry : Retry
@TimeoutSpec("resilient.timeout.pet")
interface PetTimeouter : Timeouter
@Component
open class PetService {
@CircuitBreakable(PetCircuitBreaker::class) //(2)!
@Retryable(PetRetry::class)
@Timeout(PetTimeouter::class)
open fun findById(id: Long): Pet? = petRepository.findById(id)
}
- Full path of the configuration section that describes this instance.
- The aspect is bound to the specification type, not to a string name.
What the processor does with a specification:
- generates its implementation and a module that publishes it, and the module is picked up by
@KoraAppautomatically — nothing has to be wired by hand; - publishes the specification interface itself as an application graph component, so it can be injected for imperative use;
- reads the configuration from exactly the path given in the annotation.
One instance per specification
All methods annotated with the same specification type share one instance, and therefore one state and one set of metrics. Two methods that must not influence each other's circuit breaker state need two specification interfaces pointing at two configuration sections.
The configuration path is absolute and is not merged with anything
The path in the annotation is the complete path to the section. There is no default section that a named section
inherits from: every value the configuration requires must be present at that exact path. Any path works, including
one outside the resilient prefix — @CircuitBreakerSpec("payment") reads the root-level payment section.
Common compile-time errors:
@CircuitBreakerSpec can only be applied to an interface— the annotation was placed on a class or a record.@CircuitBreakerSpec annotated interface 'X' must extend io.koraframework.resilient.circuitbreaker.CircuitBreaker— the interface does not extend the contract.config path can't be blank— the annotation value is an empty string.@CircuitBreakable on 'X#y()' references an invalid resilient component type— the class passed to the method annotation does not implement the expected contract.
CircuitBreaker¶
CircuitBreaker is a proxy that controls the request flow to a particular method
and can temporarily prohibit execution of this method if it throws many exceptions matching the configured filter.
The purpose of applying CircuitBreaker is to give the system time to correct the error that caused the failure before allowing the application to attempt the operation again.
The CircuitBreaker pattern provides stability while the system recovers from the failure and reduces the impact on performance.
CircuitBreaker can be in one of several states: CLOSED, OPEN, HALF_OPEN.
CLOSED: an application request is passed to the protected operation. The proxy counts recent failures within the configured number of operations (countBased.windowSize) passing through it, and increments this count when the operation does not complete successfully. If the number of requests exceeds the minimum amount required for calculation (minimumRequiredCalls) and the number of recent failures exceeds the configured threshold (failureRateThreshold), the proxy moves toOPEN.OPEN: While in this status, the request from the application immediately terminates with an error and an exception is returned to the application. At this point, the proxy starts a wait timer (waitDurationInOpenState), and when it expires, the proxy moves toHALF_OPEN.HALF_OPEN: a limited number of requests (permittedCallsInHalfOpenState) from the application are allowed to pass through and invoke the operation. If these requests are successful, it is assumed that the error that previously caused the failure has been resolved, andCircuitBreakerenters theCLOSEDstate (the failure counter is reset). If any request terminates with a failure,CircuitBreakerassumes that the fault is still present, so it returns to theOPENstate and restarts the wait time timer (waitDurationInOpenState) to give the system additional time to recover from the failure.
The HALF_OPEN state helps prevent requests to the service from growing rapidly: after recovery starts, the service may be able to handle only a limited number of requests for some time.
Initially it has the CLOSED state.
Declarative usage¶
If CircuitBreaker is in the OPEN state, the call fails with CallNotPermittedException.
Configuration¶
The section that @CircuitBreakerSpec points at is described by the CircuitBreakerConfig class:
resilient {
circuitbreaker {
custom {
type = STRIPED_APPROX //(1)!
failureRateThreshold = 50 //(2)!
minimumRequiredCalls = 10 //(3)!
waitDurationInOpenState = "25s" //(4)!
permittedCallsInHalfOpenState = 15 //(5)!
enabled = true //(6)!
countBased {
windowSize = 100 //(7)!
stripedApprox {
stripes = 16 //(8)!
}
}
}
}
}
- Implementation of the call window:
STRIPED_APPROX,FIXED_WINDOW,RING_BUFFERorTIME_BASED(default:STRIPED_APPROX). - Percentage of failed requests required to transition to
OPEN; the value must be from1to100(required, no default). - Minimum number of requests required to start state calculation (required, no default).
- Waiting time in
OPEN, after which the transition toHALF_OPENis performed (required, no default). - Number of requests in
HALF_OPENthat must complete successfully to transition toCLOSED(required, no default). - Enable or disable
CircuitBreaker(default:true). - Maximum number of requests used to calculate
failureRateThresholdand determine the state (required for every type exceptTIME_BASED, no default). - Number of independent counter stripes, from
1to64; used only bySTRIPED_APPROX(default:16).
resilient:
circuitbreaker:
custom:
type: STRIPED_APPROX #(1)!
failureRateThreshold: 50 #(2)!
minimumRequiredCalls: 10 #(3)!
waitDurationInOpenState: "25s" #(4)!
permittedCallsInHalfOpenState: 15 #(5)!
enabled: true #(6)!
countBased:
windowSize: 100 #(7)!
stripedApprox:
stripes: 16 #(8)!
- Implementation of the call window:
STRIPED_APPROX,FIXED_WINDOW,RING_BUFFERorTIME_BASED(default:STRIPED_APPROX). - Percentage of failed requests required to transition to
OPEN; the value must be from1to100(required, no default). - Minimum number of requests required to start state calculation (required, no default).
- Waiting time in
OPEN, after which the transition toHALF_OPENis performed (required, no default). - Number of requests in
HALF_OPENthat must complete successfully to transition toCLOSED(required, no default). - Enable or disable
CircuitBreaker(default:true). - Maximum number of requests used to calculate
failureRateThresholdand determine the state (required for every type exceptTIME_BASED, no default). - Number of independent counter stripes, from
1to64; used only bySTRIPED_APPROX(default:16).
The telemetry key inside the same section overrides the module-wide settings described in Telemetry.
Constraints
The following are validated when the graph is built — violating any of them fails application startup with an explicit
CircuitBreaker '<name>' property '<key>' ... message:
countBased is required for every type except TIME_BASED and timeBased is required for TIME_BASED;
failureRateThreshold must be in range 1..100; countBased.windowSize ≥ 1; minimumRequiredCalls ≥ 1
and ≤ countBased.windowSize; permittedCallsInHalfOpenState must be in range 1..65535;
waitDurationInOpenState must not be negative;
countBased.stripedApprox.stripes must be in range 1..64 and countBased.windowSize must not exceed stripes * 65535;
countBased.windowSize must not exceed 4194304 for RING_BUFFER.
Note
Setting enabled = false turns the aspect into a transparent pass-through — the method is invoked directly with no circuit-breaking.
The remaining values are still read and validated, because the configuration object is built either way.
Module metrics are described in the Metrics Reference section.
Implementations¶
type selects how the CLOSED-state statistics are collected. The state machine itself is identical and strictly atomic in all four.
type |
Window | Statistics | When to use |
|---|---|---|---|
STRIPED_APPROX |
count-based, countBased.windowSize |
approximate — writes are spread over independent stripes | default; the fastest option on hot, highly concurrent paths |
FIXED_WINDOW |
count-based, countBased.windowSize |
fixed counter, reset once the window fills up | lowest overhead, a single packed counter, no exact history of the last N calls |
RING_BUFFER |
count-based, countBased.windowSize |
exact history of the last N calls in global order | when exact count-based semantics matter more than coordination overhead |
TIME_BASED |
time-based, timeBased.windowDuration |
latest time window, eventually consistent around bucket rollover | when the load rate varies and a fixed number of calls is not a meaningful window |
TIME_BASED ignores countBased and reads its own section instead:
resilient {
circuitbreaker {
custom {
type = TIME_BASED
failureRateThreshold = 50
minimumRequiredCalls = 10
waitDurationInOpenState = "25s"
permittedCallsInHalfOpenState = 15
timeBased {
windowDuration = "10s" //(1)!
sampleCount = 16 //(2)!
counterStripes = 16 //(3)!
counterType = ATOMIC //(4)!
}
}
}
}
- Length of the time window the failure rate is computed over (required for
TIME_BASED, no default). - Number of buckets the window is split into, from
1to1024(default:16). - Number of independent counter stripes inside a bucket, from
1to64(default:16). - Counter implementation:
ATOMICfor predictable reset semantics,LONG_ADDERfor heavy contention at the cost of more approximate resets around bucket boundaries (default:ATOMIC).
resilient:
circuitbreaker:
custom:
type: TIME_BASED
failureRateThreshold: 50
minimumRequiredCalls: 10
waitDurationInOpenState: "25s"
permittedCallsInHalfOpenState: 15
timeBased:
windowDuration: "10s" #(1)!
sampleCount: 16 #(2)!
counterStripes: 16 #(3)!
counterType: ATOMIC #(4)!
- Length of the time window the failure rate is computed over (required for
TIME_BASED, no default). - Number of buckets the window is split into, from
1to1024(default:16). - Number of independent counter stripes inside a bucket, from
1to64(default:16). - Counter implementation:
ATOMICfor predictable reset semantics,LONG_ADDERfor heavy contention at the cost of more approximate resets around bucket boundaries (default:ATOMIC).
Exception filtering¶
CircuitBreaker records all errors as failures by default. There are two ways to change that.
The simplest one is to override isFailure right on the specification interface — no extra component and no configuration:
The second way is a CircuitBreakerPredicate component bound to the specification with @Tag. It takes precedence over
isFailure and is the right choice when the filter itself needs dependencies:
@Tag(CustomCircuitBreaker.class) //(1)!
@Component
public final class MyFailurePredicate implements CircuitBreakerPredicate {
@Override
public boolean isCircuitBreakerFailure(Throwable throwable) { //(2)!
return !(throwable instanceof HttpServerResponseException e) || e.code() >= 500;
}
}
- Binds the predicate to one specification; without the tag the predicate is not picked up.
- Returning
truerecords the exception as a failure.
@Tag(CustomCircuitBreaker::class) //(1)!
@Component
class MyFailurePredicate : CircuitBreakerPredicate {
override fun isCircuitBreakerFailure(throwable: Throwable): Boolean = //(2)!
throwable !is HttpServerResponseException || throwable.code() >= 500
}
- Binds the predicate to one specification; without the tag the predicate is not picked up.
- Returning
truerecords the exception as a failure.
An exception that the filter rejects is neither counted as a failure nor as a success: it is simply ignored by the breaker and propagates to the caller unchanged.
Imperative usage¶
The specification interface is an ordinary application graph component, so a breaker can be used in imperative code by injecting it directly:
@Component
public final class SomeService {
private final CustomCircuitBreaker circuitBreaker;
public SomeService(CustomCircuitBreaker circuitBreaker) {
this.circuitBreaker = circuitBreaker;
}
public String doWork() {
return circuitBreaker.accept(this::doSomeWork);
}
private String doSomeWork() {
// do some work
}
}
The accept methods take ThrowableCallable<T, E> or ThrowableRunnable<E> from io.koraframework.resilient.common,
so the protected code may throw checked exceptions.
To return a fallback value instead of throwing CallNotPermittedException when the breaker is OPEN, use the accept overload that takes a second callable:
When the protected call cannot be wrapped in a single callable, acquire and release the permit manually.
Call acquire() (throws CallNotPermittedException when the breaker is OPEN, or HALF_OPEN with no test calls left) to obtain a permit, then always report the outcome with releaseOnSuccess() or releaseOnError(Throwable) — otherwise the breaker leaks a permit and its accounting becomes incorrect:
tryAcquire() is the non-throwing alternative: it returns false when the call is not permitted, so you can branch without catching CallNotPermittedException.
When acquire() does throw, the current breaker state (OPEN or HALF_OPEN) is available via CallNotPermittedException#state().
Retry¶
Retry provides the ability to configure repeated invocation of annotated methods.
It allows you to specify when a method should be retried and configure retry parameters when the method throws an exception matching the configured filter.
Declarative usage¶
If all attempts are exhausted, the call fails with RetryExhaustedException.
Configuration¶
The section that @RetrySpec points at is described by the RetryConfig class:
resilient {
retry {
custom {
delay = "100ms" //(1)!
attempts = 2 //(2)!
delayStep = "100ms" //(3)!
enabled = true //(4)!
}
}
}
- Initial delay before a repeated call (required, no default).
- Number of retry attempts (required, no default).
- Delay increment for subsequent attempts; ignored when
backoffis configured (default:0). - Enable or disable
Retry(default:true).
resilient:
retry:
custom:
delay: "100ms" #(1)!
attempts: 2 #(2)!
delayStep: "100ms" #(3)!
enabled: true #(4)!
- Initial delay before a repeated call (required, no default).
- Number of retry attempts (required, no default).
- Delay increment for subsequent attempts; ignored when
backoffis configured (default:0). - Enable or disable
Retry(default:true).
The optional backoff, jitter and retryBudget sections are described in Backoff and jitter and
Retry budget, and the telemetry key overrides the settings from Telemetry.
Constraints & delay progression
delay and attempts are required and startup fails without them.
attempts counts the retries after the initial call, so attempts = 2 allows up to 3 executions in total, and
attempts = 0 turns the aspect into a transparent pass-through.
With no backoff section each retry waits delayStep (default 0) longer than the previous one, so the delays are
delay, delay + delayStep, delay + 2·delayStep, … .
Note
Setting enabled = false turns @Retryable into a transparent pass-through (the method runs once).
A retry that runs out of attempts throws RetryExhaustedException whose getCause() is the last failure and whose
suppressed exceptions are all the earlier failures.
Backoff and jitter¶
The backoff section replaces the linear delayStep progression with an exponential one, and jitter spreads the
delays of concurrent callers so they do not retry in lockstep:
resilient {
retry {
custom {
delay = "100ms"
attempts = 4
backoff {
type = EXPONENTIAL //(1)!
multiplier = 2.0 //(2)!
delayMax = "5s" //(3)!
}
jitter {
type = FULL //(4)!
ratio = 1.0 //(5)!
}
}
}
}
- Backoff strategy; the only supported value is
EXPONENTIAL(default:EXPONENTIAL). - Multiplier applied on every attempt, must be greater than
0(default:2.0). - Upper bound for the computed delay (optional, unbounded by default).
- Jitter strategy:
NONEdisables it,FULLrandomizes the delay (default:NONE). - Fraction of the computed delay that may be subtracted, must be in range
0..1(default:1.0).
resilient:
retry:
custom:
delay: "100ms"
attempts: 4
backoff:
type: EXPONENTIAL #(1)!
multiplier: 2.0 #(2)!
delayMax: "5s" #(3)!
jitter:
type: FULL #(4)!
ratio: 1.0 #(5)!
- Backoff strategy; the only supported value is
EXPONENTIAL(default:EXPONENTIAL). - Multiplier applied on every attempt, must be greater than
0(default:2.0). - Upper bound for the computed delay (optional, unbounded by default).
- Jitter strategy:
NONEdisables it,FULLrandomizes the delay (default:NONE). - Fraction of the computed delay that may be subtracted, must be in range
0..1(default:1.0).
With the settings above the computed delay for attempt n is delay * multiplier^(n-1), capped at delayMax:
100ms, 200ms, 400ms, 800ms. Jitter then picks the actual delay uniformly from
[computed - computed * ratio, computed], so ratio = 1.0 means anything from 0 up to the computed delay.
Retry budget¶
A retry budget caps how much extra load retries may add. It is a token bucket: every retry takes one token, every
successful call returns ratio tokens, and when the bucket is empty the retry is refused and the original exception is
rethrown as is — without waiting and without RetryExhaustedException.
resilient {
retry {
custom {
delay = "100ms"
attempts = 3
retryBudget {
enabled = true //(1)!
ratio = 0.1 //(2)!
tokensMax = 100 //(3)!
tokensInitial = 10 //(4)!
minTokensPerSecond = 0.0 //(5)!
}
}
}
}
- Enable or disable the budget (default:
true). - Tokens added per successful call —
0.1allows roughly one retry per ten successful calls (default:0.1). - Upper bound of the bucket (default:
100). - Number of tokens the bucket starts with, must not exceed
tokensMax(default:10). - Guaranteed refill rate that applies even without successful calls (default:
0.0).
resilient:
retry:
custom:
delay: "100ms"
attempts: 3
retryBudget:
enabled: true #(1)!
ratio: 0.1 #(2)!
tokensMax: 100 #(3)!
tokensInitial: 10 #(4)!
minTokensPerSecond: 0.0 #(5)!
- Enable or disable the budget (default:
true). - Tokens added per successful call —
0.1allows roughly one retry per ten successful calls (default:0.1). - Upper bound of the bucket (default:
100). - Number of tokens the bucket starts with, must not exceed
tokensMax(default:10). - Guaranteed refill rate that applies even without successful calls (default:
0.0).
Note
The budget is off unless the retryBudget section is declared. Declaring it with enabled = false also leaves it off.
Exception filtering¶
Retry retries on every error by default, and the two ways to narrow that down mirror the
circuit breaker: override isFailure on the specification, or register a RetryPredicate
component tagged with the specification. The tagged component wins when both are present.
@RetrySpec("resilient.retry.custom")
public interface CustomRetry extends Retry {
@Override
default boolean isFailure(Throwable throwable) {
return throwable instanceof IOException;
}
}
@Tag(CustomRetry.class)
@Component
public final class MyRetryPredicate implements RetryPredicate {
@Override
public boolean isRetryFailure(Throwable throwable) {
return throwable instanceof IOException;
}
}
@RetrySpec("resilient.retry.custom")
interface CustomRetry : Retry {
override fun isFailure(throwable: Throwable): Boolean = throwable is IOException
}
@Tag(CustomRetry::class)
@Component
class MyRetryPredicate : RetryPredicate {
override fun isRetryFailure(throwable: Throwable): Boolean = throwable is IOException
}
An exception the filter rejects is rethrown immediately, without further attempts and without being wrapped in
RetryExhaustedException.
Imperative usage¶
Inject the specification interface and call it directly:
To return a fallback value instead of throwing RetryExhaustedException once all attempts are used up, pass a second callable:
For asynchronous imperative code there is an overload that retries a Supplier<CompletionStage<T>> and returns a CompletionStage<T>, scheduling each attempt after the configured delay without blocking the calling thread.
Manual retry state¶
For full control over the retry loop, use retry.asState(), which returns a Retry.RetryState.
It is AutoCloseable, so wrap it in try-with-resources (Java) or use (Kotlin) to record metrics on completion.
On each caught exception call onException(Throwable), which returns a RetryStatus:
ACCEPTED— another attempt is allowed; calldoDelay()(blocks for the current backoff) and retry.REJECTED— the exception was rejected by the filter or the retry budget is empty, and it must not be retried; rethrow it.EXHAUSTED— all attempts are used up; throwRetryExhaustedException(or fall back to a default).
getAttempts() / getAttemptsMax() report progress and getDelayNanos() returns the next delay.
public String doWork() {
try (var state = retry.asState()) {
while (true) {
try {
return doSomeWork();
} catch (Exception e) {
switch (state.onException(e)) {
case ACCEPTED -> state.doDelay(); // wait, then loop and retry
case REJECTED -> throw e; // not retryable
case EXHAUSTED -> throw new RetryExhaustedException("custom", state.getAttemptsMax(), e);
}
}
}
}
}
fun doWork(): String {
retry.asState().use { state ->
while (true) {
try {
return doSomeWork()
} catch (e: Exception) {
when (state.onException(e)) {
Retry.RetryState.RetryStatus.ACCEPTED -> state.doDelay() // wait, then loop and retry
Retry.RetryState.RetryStatus.REJECTED -> throw e // not retryable
Retry.RetryState.RetryStatus.EXHAUSTED -> throw RetryExhaustedException("custom", state.attemptsMax, e)
}
}
}
}
}
Timeout¶
Timeout sets the maximum execution time of the annotated method.
For synchronous methods the call is offloaded to a virtual thread and interrupted once the limit is reached; for Kotlin
suspend functions the coroutine is cancelled by withTimeout.
Declarative usage¶
If the method does not complete within duration, the call fails with TimeoutExhaustedException.
@TimeoutSpec("resilient.timeout.custom")
public interface CustomTimeouter extends Timeouter { }
@Component
public class SomeService {
@Timeout(CustomTimeouter.class)
public String getValue() {
try {
Thread.sleep(3000);
return "OK";
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
}
Configuration¶
The section that @TimeoutSpec points at is described by the TimeoutConfig class:
- Operation time limit after which
TimeoutExhaustedExceptionwill be thrown (required, no default). - Enable or disable
Timeout(default:true).
The telemetry key inside the same section overrides the module-wide settings described in Telemetry.
Note
duration is required and startup fails without it.
Setting enabled = false turns @Timeout into a transparent pass-through — the method runs with no time limit.
An exception thrown by the method before the limit is reached propagates unchanged, including checked exceptions.
Imperative usage¶
Inject the specification interface and call it directly:
Timeouter also exposes an execute overload for operations that return nothing, and timeout() returns the configured Duration:
RateLimiter¶
RateLimiter caps how many times a method may be called within a period. The limiter is a fixed-window counter: it hands
out limitForPeriod permits, and the counter is reset to that value on the first call after limitRefreshPeriod has
elapsed. Acquiring a permit never blocks — a call that finds no permit left fails immediately with RateLimitExceededException.
Declarative usage¶
Configuration¶
The section that @RateLimiterSpec points at is described by the RateLimiterConfig class:
resilient {
ratelimiter {
custom {
limitForPeriod = 100 //(1)!
limitRefreshPeriod = "1s" //(2)!
enabled = true //(3)!
}
}
}
- Number of calls permitted within one period (required, no default).
- Length of the period after which the permits are replenished (required, no default).
- Enable or disable
RateLimiter(default:true).
resilient:
ratelimiter:
custom:
limitForPeriod: 100 #(1)!
limitRefreshPeriod: "1s" #(2)!
enabled: true #(3)!
- Number of calls permitted within one period (required, no default).
- Length of the period after which the permits are replenished (required, no default).
- Enable or disable
RateLimiter(default:true).
The telemetry key inside the same section overrides the module-wide settings described in Telemetry.
Note
Setting enabled = false turns @RateLimited into a transparent pass-through — every call is permitted.
The limiter is per application instance: with several replicas the effective limit is limitForPeriod multiplied by the number of replicas.
Imperative usage¶
Inject the specification interface and call it directly:
@Component
public final class SomeService {
private final CustomRateLimiter rateLimiter;
public SomeService(CustomRateLimiter rateLimiter) {
this.rateLimiter = rateLimiter;
}
public String doWork() {
return rateLimiter.execute(this::doSomeWork); //(1)!
}
public boolean doWorkIfPermitted() {
if (!rateLimiter.tryAcquire()) { //(2)!
return false;
}
doSomeWork();
return true;
}
private String doSomeWork() {
// do some work
}
}
- Acquires a permit and runs the operation, throwing
RateLimitExceededExceptionwhen the limit is exhausted. - Non-throwing alternative: returns
falseinstead of raising an exception.
@Component
class SomeService(private val rateLimiter: CustomRateLimiter) {
fun doWork(): String {
return rateLimiter.execute(ThrowableCallable { doSomeWork() }) //(1)!
}
fun doWorkIfPermitted(): Boolean {
if (!rateLimiter.tryAcquire()) { //(2)!
return false
}
doSomeWork()
return true
}
private fun doSomeWork(): String {
// do some work
}
}
- Acquires a permit and runs the operation, throwing
RateLimitExceededExceptionwhen the limit is exhausted. - Non-throwing alternative: returns
falseinstead of raising an exception.
acquire() takes a permit without running anything and throws RateLimitExceededException when there is none left.
Fallback¶
Fallback specifies a method that is called when the annotated method fails.
Unlike the other mechanisms it has no specification interface and no configuration section of its own: the fallback method
is the whole contract.
The fallback method must match the return type of the annotated method and must be declared in the same class.
Declarative usage¶
An example of a backup method with no arguments:
Example for Fallback with arguments:
@Component
public class SomeService {
@Fallback(method = "getFallback(arg3, arg1)") // Passes the arguments of the annotated method in the specified order to the Fallback method
public String getValue(String arg1, Integer arg2, Long arg3) {
return "value";
}
protected String getFallback(Long argLong, String argString) {
return "fallback";
}
}
@Component
open class SomeService {
// Passes the arguments of the annotated method in the specified order to the Fallback method
@Fallback(method = "getFallback(arg3, arg1)")
open fun getValue(arg1: String, arg2: Int, arg3: Long): String = "value"
fun getFallback(argLong: Long, argString: String): String = "fallback"
}
The reference is validated at compile time. Common errors:
@Fallback method reference '…' has invalid syntax— the value must look likename()orname(arg1, arg2).@Fallback method reference '…' uses unknown source arguments— an argument that the annotated method does not declare.@Fallback method '…' was not found— no method with that name in the same class.@Fallback method '…' does not match requested signature— the fallback method must accept exactly the referenced arguments plus an optional@Fallback.Reasonparameter.
Exception filtering¶
By default the fallback is invoked for every Throwable. Adding a single @Fallback.Reason parameter to the fallback
method both delivers the exception that triggered the fallback and narrows down what triggers it: an exception that is not
an instance of the declared parameter type is rethrown and the fallback is not called.
@Component
public class SomeService {
@Fallback(method = "getFallback()")
public String getValue() {
throw new IllegalStateException("Ops");
}
protected String getFallback(@Fallback.Reason RuntimeException reason) { //(1)!
return "fallback: " + reason.getMessage();
}
}
- At most one such parameter is allowed and it is not part of the argument list in
method = "...".
@Component
open class SomeService {
@Fallback(method = "getFallback()")
open fun value(): String = throw IllegalStateException("Ops")
fun getFallback(@Fallback.Reason reason: RuntimeException): String = //(1)!
"fallback: " + reason.message
}
- At most one such parameter is allowed and it is not part of the argument list in
method = "...".
In Java the parameter type must match what the annotated method can throw: RuntimeException when it declares no
throws, Exception when it declares checked exceptions, and Throwable when it declares throws Throwable.
A narrower type is reported as a compile-time error, so use a plain instanceof check inside the fallback for finer filtering.
Telemetry¶
Logging, metrics and tracing are configured per mechanism under resilient.telemetry, and every specification may
override the module-wide values through a telemetry key inside its own configuration section:
resilient {
telemetry {
circuitBreaker { //(1)!
logging.enabled = false //(2)!
metrics {
enabled = false //(3)!
tags { "service" = "pets" } //(4)!
}
tracing {
enabled = false //(5)!
attributes { "component" = "resilient" } //(6)!
}
}
retry {}
timeout {}
fallback {}
rateLimiter {}
}
circuitbreaker {
custom {
telemetry.metrics.enabled = true //(7)!
}
}
}
- Sections:
circuitBreaker,retry,timeout,fallback,rateLimiter. - Enable logging for the mechanism (default:
false). - Enable metrics for the mechanism (default:
false). - Extra tags added to every metric of the mechanism (default: empty).
- Enable tracing for the mechanism (default:
false). - Extra attributes added to every span of the mechanism (default: empty).
- Per-specification override; unset keys fall back to the values under
resilient.telemetry.
resilient:
telemetry:
circuitBreaker: #(1)!
logging:
enabled: false #(2)!
metrics:
enabled: false #(3)!
tags:
service: "pets" #(4)!
tracing:
enabled: false #(5)!
attributes:
component: "resilient" #(6)!
retry: {}
timeout: {}
fallback: {}
rateLimiter: {}
circuitbreaker:
custom:
telemetry:
metrics:
enabled: true #(7)!
- Sections:
circuitBreaker,retry,timeout,fallback,rateLimiter. - Enable logging for the mechanism (default:
false). - Enable metrics for the mechanism (default:
false). - Extra tags added to every metric of the mechanism (default: empty).
- Enable tracing for the mechanism (default:
false). - Extra attributes added to every span of the mechanism (default: empty).
- Per-specification override; unset keys fall back to the values under
resilient.telemetry.
The whole resilient.telemetry block is optional — omitting it leaves every mechanism with the defaults above.
The metric names are listed in the Metrics Reference.
Combination¶
It is possible to combine all of the above annotations simultaneously over a single method.
The order in which the annotations are applied depends on the order in which the annotations are declared. You can change the order as you wish and combine it with other annotations that are also applied in the order of declaration.
@Component
public class SomeService {
@Fallback(method = "getFallback(arg1)") // 4
@CircuitBreakable(CustomCircuitBreaker.class) // 3
@Retryable(CustomRetry.class) // 2
@Timeout(CustomTimeouter.class) // 1
public String getValueSync(String arg1) {
return "result-" + arg1;
}
protected String getFallback(String arg1) { // 4
return "fallback-" + arg1;
}
}
@Component
open class SomeService {
@Fallback(method = "getFallback(arg1)") // 4
@CircuitBreakable(CustomCircuitBreaker::class) // 3
@Retryable(CustomRetry::class) // 2
@Timeout(CustomTimeouter::class) // 1
open fun getValueSync(arg1: String): String = "result-$arg1"
protected fun getFallback(arg1: String): String = "fallback-$arg1" // 4
}
In the example above:
@Timeoutis applied and checks that the method does not run longer than the time specified in the configuration.@Retryableis applied and attempts to repeat method execution the configured number of times if the method throws an exception in the chain, including an exception from@Timeout.@CircuitBreakableis applied and works according to its configuration and state, depending on the successful method result or an exception in the chain, including exceptions from@Timeoutand@Retryable.@Fallbackis applied and calls thegetFallbackmethod with thearg1argument if the method throws an exception in the chain, including exceptions from@Timeout,@Retryable, and@CircuitBreakable.
Aspect invocation order follows the annotation order on the method: from top to bottom, so the topmost annotation is the outermost wrapper.
@RateLimited fits into the same chain and is usually placed above @CircuitBreakable, so rejected calls never reach the breaker.
Example configuration for all aspects:
resilient {
circuitbreaker {
custom {
type = FIXED_WINDOW
countBased.windowSize = 1
minimumRequiredCalls = 1
failureRateThreshold = 100
permittedCallsInHalfOpenState = 1
waitDurationInOpenState = "1s"
}
}
timeout {
custom {
duration = "300ms"
}
}
retry {
custom {
delay = "100ms"
attempts = 2
}
}
ratelimiter {
custom {
limitForPeriod = 100
limitRefreshPeriod = "1s"
}
}
}
resilient:
circuitbreaker:
custom:
type: FIXED_WINDOW
countBased:
windowSize: 1
minimumRequiredCalls: 1
failureRateThreshold: 100
permittedCallsInHalfOpenState: 1
waitDurationInOpenState: "1s"
timeout:
custom:
duration: "300ms"
retry:
custom:
delay: "100ms"
attempts: 2
ratelimiter:
custom:
limitForPeriod: 100
limitRefreshPeriod: "1s"
Exceptions¶
All resilience exceptions extend io.koraframework.resilient.exception.ResilientException (a RuntimeException), which exposes name() — the simple name of the specification interface that raised it.
| Exception | Thrown by | Additional API |
|---|---|---|
ResilientException |
base type for all of the below | name() |
CallNotPermittedException |
@CircuitBreakable / CircuitBreaker#acquire() when the breaker is OPEN, or HALF_OPEN with no test calls left |
state() returns the CircuitBreaker.State (OPEN / HALF_OPEN) |
RetryExhaustedException |
@Retryable / Retry#retry(...) when every attempt failed |
name(); message carries the number of attempts, the last failure is the getCause(), earlier failures are suppressed |
TimeoutExhaustedException |
@Timeout / Timeouter#execute(...) when the method exceeds duration |
name() |
RateLimitExceededException |
@RateLimited / RateLimiter#acquire() when no permit is left in the current period |
name() |
Each exception lives in the exception sub-package of its mechanism, for example io.koraframework.resilient.circuitbreaker.exception.CallNotPermittedException.
Description — resilience aspects signal failure by throwing one of these unchecked exceptions out of the protected method.
Causes
CallNotPermittedException— the circuit breaker is short-circuiting calls because the failure rate reachedfailureRateThreshold; the call was rejected without invoking the method.RetryExhaustedException— the method kept throwing a retryable exception untilattemptswas reached; the underlying failure is available viagetCause().TimeoutExhaustedException— the method did not complete withinduration.RateLimitExceededException—limitForPeriodcalls were already permitted within the currentlimitRefreshPeriod.
Recommendations
- Catch
ResilientExceptionto handle any resilience failure uniformly, or catch the concrete type when the handling differs. - When aspects are combined, a downstream aspect's exception propagates up the chain: e.g. a
TimeoutExhaustedExceptionfrom@Timeoutis observed by@Retryable, then@CircuitBreakable, and finally@Fallback. Prefer a@Fallbackmethod or an imperative fallback over turning these into user-facing errors. - An exception rejected by an exception filter is rethrown as is and never wrapped, so callers still see the original type.
Handling example:
try {
return service.getValue();
} catch (CallNotPermittedException e) {
log.warn("CircuitBreaker '{}' is {}", e.name(), e.state());
return cachedValue();
} catch (TimeoutExhaustedException | RetryExhaustedException | RateLimitExceededException e) {
log.warn("Resilient '{}' failed", e.name(), e);
return cachedValue();
}
try {
return service.value()
} catch (e: CallNotPermittedException) {
log.warn("CircuitBreaker '{}' is {}", e.name(), e.state())
return cachedValue()
} catch (e: ResilientException) { // TimeoutExhaustedException, RetryExhaustedException, RateLimitExceededException, ...
log.warn("Resilient '{}' failed", e.name(), e)
return cachedValue()
}
Signatures¶
Available method signatures supported by these annotations out of the box.
Reactive return types (Mono, Flux and any other Publisher) are not supported by any of the resilience aspects.
Class must be non final in order for aspects to work.
T means the return value type.
void myMethod()T myMethod()Optional<T> myMethod()CompletionStage<T> myMethod()/CompletableFuture<T> myMethod()(CompletionStage) — supported by every annotation except@RateLimited
Class must be open in order for aspects to work.
By T we mean the type of the return value, either T?, or Unit.
myMethod(): Tsuspend myMethod(): T(Kotlin Coroutines, requires dependency asimplementation)myMethod(): Flow<T>(Kotlin Coroutines, requires dependency asimplementation)
CompletionStage and CompletableFuture are not supported in Kotlin — use suspend instead.
Applying an aspect to an unsupported return type is a compile-time error of the form
@Retryable cannot be applied to '…' because return type '…' is not supported by this aspect.