Cache & Hedge
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Two Resilience4j modules sit apart from the five that guard a call against failure (CircuitBreaker, Retry, RateLimiter, Bulkhead, TimeLimiter). Cache avoids the call altogether when a recent answer is stored, and Hedge answers a slow call by asking a second time before the first one has finished. Neither has Spring Boot properties, annotations, Actuator endpoints or Micrometer meters, so both are used programmatically, typically as plain beans in a configuration class. This page covers both end to end. It was written against Resilience4j 2.4.0, Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21, and every example on it was compiled and run on those versions on 4 October 2026 (the Cache examples with the Caffeine JCache provider 3.2.4, the Hedge examples against a local WireMock server). Both modules are independent of the Spring Boot starter, so nothing in them differs between Boot 4 and Boot 3.5.x. The programming model they share with the other modules (decorators, registries, event publishers) is in Registries, Configuration & Decorators; how a cache or a hedge fits with the other patterns is in Fallbacks & Composition; the vocabulary (idempotency, tail latency tradeoffs) is in Resilience Fundamentals.
In the running Bookshelf scenario, the third-party ISBN-metadata API has a quota and a slow tail, and its answers
(a title for an ISBN) hardly ever change, which makes it the ideal candidate for a cache. The pricing-service is
read-only for the catalogue page and occasionally answers from a slow replica, which makes it the candidate for a hedge.
Part 1: the Cache module
What it is, and what it is not
resilience4j-cache wraps a JCache (JSR-107, package javax.cache) Cache in a Resilience4j Cache<K, V> that does
one thing: a read-through lookup. Given a key and a function that produces the value, it returns the cached value if
there is one, and otherwise calls the function, stores the result and returns it. It adds three things to the JCache
API: hit, miss and error events, a hit/miss counter, and the decorator shape that composes with the other modules.
It is not a cache. It stores nothing itself, has no size limit, no expiry, no eviction and no serialisation of its own:
all of that comes from the JCache provider you put on the classpath. The module depends only on resilience4j-core
and the JCache API (javax.cache:cache-api; the 2.4.0 build uses 1.1.0 and Spring Boot 4.1.1 resolved 1.1.1 in the
sample project). The JCache API (1.1.x) still lives in the javax.cache package, not jakarta.cache,
and that is the package every provider in this page uses.
The flow in the diagram is the whole module, CacheImpl in the source. The sections below walk through its consequences.
Dependencies
resilience4j-cache is in the resilience4j-bom and is a dependency of resilience4j-all (the module that provides
Decorators), so with resilience4j-all you already have it. The resilience4j-spring-boot4 starter does not
bring it. A JCache provider is separate and is your choice; the example below uses the Caffeine one.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-bom</artifactId>
<version>2.4.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-cache</artifactId>
</dependency>
<!-- a JCache provider; Caffeine's is one of several (see "Choosing a provider") -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>jcache</artifactId>
<version>3.2.4</version>
</dependency>
</dependencies>
Source: Resilience4j: Cache and the
resilience4j-cache module at v2.4.0.
The Caffeine artifact is documented in the Caffeine JCache wiki page.
With exactly one JCache provider on the classpath, Caching.getCachingProvider() finds it through the ServiceLoader
(the test printed CaffeineCachingProvider). With more than one, name the provider explicitly or the call fails.
Cache.of and the decorators
Cache.of(javax.cache.Cache) wraps an existing JCache instance. The static decorateSupplier, decorateCallable and
decorateCheckedSupplier helpers turn a supplier into a function of the cache key, which is the one place the Cache
differs from the other modules, whose decorators keep the shape of the decorated function.
CacheManager manager = Caching.getCachingProvider().getCacheManager();
javax.cache.Cache<String, String> jcache = manager.createCache("isbn-titles",
new MutableConfiguration<String, String>().setTypes(String.class, String.class));
Cache<String, String> cache = Cache.of(jcache);
cache.getEventPublisher()
.onCacheHit(e -> log.info("HIT {}", e.getCacheKey()))
.onCacheMiss(e -> log.info("MISS {}", e.getCacheKey()))
.onError(e -> log.warn("cache error {}", e.getThrowable().toString()));
AtomicInteger backend = new AtomicInteger();
Function<String, String> title = Cache.decorateSupplier(cache,
() -> "Title #" + backend.incrementAndGet());
title.apply("978-0"); // MISS, backend call
title.apply("978-0"); // HIT
title.apply("978-1"); // MISS, backend call
// cache.getMetrics(): hits = 1, misses = 2; the backend ran twice
Source: Resilience4j: Cache, adapted. The readme.io page shows Vavr’s
CheckedFunction1 and Try; since Resilience4j 2.0.0 Vavr is gone from the core and the checked function type is
io.github.resilience4j.core.functions.CheckedFunction. Run: the events were [MISS 978-0, HIT 978-0, MISS 978-1], and
getName() returned the JCache name isbn-titles.
Decorators (from resilience4j-all) adds withCache(cache) to the Supplier and CheckedSupplier builders. It
returns a DecorateFunction (a DecorateCheckedFunction for the checked builder), because it turns the chain into a function of
the key, so it is the last decoration of a chain:
CheckedFunction<String, String> cached = Decorators
.ofCheckedSupplier(() -> remoteTitle())
.withCircuitBreaker(circuitBreaker)
.withCache(cache)
.decorate();
cached.apply("978-0"); // miss: the breaker guards the remote call
cached.apply("978-0"); // hit: the breaker never sees it (1 successful call recorded)
Source: Resilience4j: Cache and
Decorators at v2.4.0.
Run: the breaker recorded exactly one successful call after two lookups.
withCache is defined only for Supplier and CheckedSupplier decorations, not for CompletionStage,
Callable, Runnable or reactive types, and the Kotlin and Reactor modules have no cache operator.
For a reactive pipeline, wrap the blocking lookup yourself or use a reactive cache library.
Where the cache goes in a chain
Decorators applies calls in the order written, the last one outermost, and withCache produces a function, so it
is always the outermost link. That is also the right place: a hit then costs a map lookup and does not consume a retry
attempt, a breaker permit, a rate-limiter permit or a bulkhead slot, and does not pollute their metrics with calls that
never reached the backend. Within the miss path the order is the usual one from
Fallbacks & Composition: Retry outside CircuitBreaker.
The Bookshelf IsbnTitles service puts all of it together, in a Spring Boot application:
@Configuration(proxyBeanMethods = false)
class IsbnCacheConfig {
@Bean
Cache<String, String> isbnTitleCache(javax.cache.CacheManager cacheManager) {
javax.cache.Cache<String, String> jcache = cacheManager.createCache("isbn-titles",
new MutableConfiguration<String, String>()
.setTypes(String.class, String.class)
.setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(Duration.ONE_HOUR)));
return Cache.of(jcache);
}
}
@Service
public class IsbnTitles {
private final IsbnMetadataClient client;
private final Cache<String, String> cache;
private final CircuitBreaker breaker;
private final Retry retry;
IsbnTitles(IsbnMetadataClient client, Cache<String, String> isbnTitleCache,
CircuitBreakerRegistry breakers, RetryRegistry retries) {
this.client = client;
this.cache = isbnTitleCache;
this.breaker = breakers.circuitBreaker("isbn");
this.retry = retries.retry("isbn");
}
public String title(String isbn) {
Supplier<String> remote = Decorators.ofSupplier(() -> client.title(isbn))
.withCircuitBreaker(breaker)
.withRetry(retry)
.decorate();
// the cache is outermost: a hit never reaches the retry, the breaker or the network
return Cache.decorateSupplier(cache, remote).apply(isbn);
}
}
Source: Resilience4j: Cache and the
Spring Boot caching reference (for the auto-configured
javax.cache.CacheManager). Run: three calls for the same ISBN produced one request to the stubbed API, two cache
hits, one miss and one successful call on the breaker. The Spring Boot javax.cache.CacheManager bean used above comes
from the JCache auto-configuration, which needs spring-boot-starter-cache, @EnableCaching and a provider on the
classpath; without them, create the manager yourself with Caching.getCachingProvider().getCacheManager().
Events and metrics
The Cache publishes three events through getEventPublisher(), and keeps two counters. Event consumers run on the
calling thread, and the event objects are created only when at least one consumer is registered.
| Event class | getEventType() |
Published when |
|---|---|---|
|
|
|
|
|
|
|
|
|
getMetrics() returns getNumberOfCacheHits() and getNumberOfCacheMisses(), two LongAdder counters that only
grow. There is no Micrometer binder, no Actuator endpoint and no health indicator for the Cache module in 2.4.0, so to
chart a hit ratio, register the two counters as gauges yourself, or use the provider’s own statistics (JCache has a
statisticsEnabled configuration flag and an MXBean). A cache that fails on every call shows up as growing error
events and, importantly, as no growth in either counter.
What happens when the cache fails
The module’s central promise, stated on the readme.io page, is that a failing cache does not fail the call: when a lookup from a distributed cache fails, the exception is absorbed and the function is called. The source and a run with a JCache wrapper that throws show exactly what that means for each way a cache can fail.
| Failure | Observed behaviour | Events and counters |
|---|---|---|
|
The exception is logged at WARN and absorbed; the call goes to the |
One |
|
The error is logged and absorbed, and the supplier runs again, outside the cache, so its value is returned. |
|
The supplier throws |
The exception reaches |
|
The supplier returns |
JCache forbids |
|
The value cannot be stored, for example because it is not |
See the next section: with Caffeine the lookup never hits, so every call reaches the backend. |
Error events; hit counter stays at zero. |
Run: with a Bookshelf backend that answered 500 to every request and Retry configured for two attempts, one
IsbnTitles.title call produced four requests to the backend (two attempts, twice) before the caller saw an
exception. That doubling comes from the Cache module re-running the supplier, not from Retry.
Two practical conclusions follow. A cache lookup that fails quickly is harmless; one that fails slowly (a Redis connection that hangs for its socket timeout) adds that delay to every call, so give the provider’s client a short timeout, or put a CircuitBreaker around the cache itself, as sketched in Near and Far Caches. And because a failing supplier may run twice, the decorated function must be safe to call twice, which is the same idempotency rule Retry has.
Choosing a provider
The readme.io page says that the JCache reference implementation (RI) should not be used in production, because it has
concurrency problems, and recommends Ehcache, Caffeine, Redisson, Hazelcast, Ignite or another provider. The RI is the
org.jsr107.ri:cache-ri-impl artifact; keep it out of production code, and use a real provider even in
integration tests when you want them to resemble production.
| Provider | Kind | Notes |
|---|---|---|
Caffeine JCache ( |
In-process, very fast |
The one used and run on this page. Same engine as Spring’s Caffeine support; expiry, size and statistics through the JCache configuration (or the provider’s own configuration file, see the Caffeine wiki). |
Ehcache 3 ( |
In-process, with disk and clustered tiers |
Implements JSR-107 (see the Ehcache JSR-107 documentation); the readme.io page has an Ehcache example. Not run here. |
Hazelcast, Apache Ignite, Redisson (Redis) |
Distributed |
JCache providers for a shared cache across instances; each needs its own client configuration, serialisation and timeouts. Named on the readme.io page. Not run here. |
Choosing a provider is the same decision as choosing a near or far cache in Near and Far Caches; the distributed ones add a network call that can fail or hang, which is exactly the case the failure rules above and a short client timeout are about.
JCache configuration that matters for correctness
The Cache module delegates all of it, so the JCache MutableConfiguration is where the real settings are:
-
Store by value is the JCache default.
new MutableConfiguration<>().isStoreByValue()istrue, so values are copied through serialisation, and with Caffeine a value that is notjava.io.Serializableis never stored. Run: a cache of a plainrecordreturned no hit on two lookups and published error events, because the backend ran on both; the same record declaredimplements Serializablehit on the second lookup, and withsetStoreByValue(false)it hit as well, returning the stored instance itself (so callers must treat cached values as immutable). -
Expiry is the time to live you configure.
CreatedExpiryPolicy.factoryOf(new Duration(MILLISECONDS, 150))made the third lookup, 400 ms later, a miss. With no expiry policy entries live until evicted, so a stale ISBN title is served forever; set a policy for anything that can change. -
The key is whatever you pass to
apply. Choose a stable, normalised key (the ISBN without hyphens, say) and include every input of the supplier in it, or two different calls will share an entry. -
The cache name belongs to the manager.
createCacheon an existing name throwsjavax.cache.CacheException(run); look it up withgetCachewhen the manager outlives your bean, for example across context refreshes in tests.
MutableConfiguration<String, SerializablePrice> config = new MutableConfiguration<String, SerializablePrice>()
.setTypes(String.class, SerializablePrice.class)
.setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(
new javax.cache.expiry.Duration(TimeUnit.MINUTES, 10)))
.setStatisticsEnabled(true);
record SerializablePrice(String isbn, long cents) implements java.io.Serializable { }
Source: the JSR-107 specification page and its
API repository. Run: the same configuration style with a 150 ms
CreatedExpiryPolicy expired an entry, and the three variants of the price record behaved as described above.
No Spring Boot properties
There is no resilience4j.cache prefix. The 2.4.0 source of resilience4j-spring6, resilience4j-framework-common and
resilience4j-spring-boot4 has no cache configuration class, no @Cache annotation, no auto-configured Cache bean
and no Actuator endpoint for it. The only Spring Boot properties that touch a JCache are Spring’s own: with
spring-boot-starter-cache, @EnableCaching and spring.cache.type=jcache, Boot builds a javax.cache.CacheManager
and a JCacheCacheManager, and spring.cache.jcache.provider and spring.cache.jcache.config select the provider and
its configuration resource. Those properties configure Spring’s cache abstraction, not Resilience4j; the
isbnTitleCache bean above simply reuses the same manager, and the printed cache names were [book-prices, isbn-titles],
one created by Boot from spring.cache.cache-names and one by the bean.
Relation to Spring’s @Cacheable
The two solve the same problem at different levels, and can coexist on the same JCache provider and manager.
| Aspect | Resilience4j Cache |
Spring @Cacheable |
|---|---|---|
Mechanism |
A decorator you apply to a function in code |
An annotation on a bean method, applied by a proxy (so self-invocation bypasses it) |
Cache key |
The argument of |
Derived from the method arguments, or a SpEL |
Backing stores |
JCache (JSR-107) only |
Any |
Behaviour when the cache fails |
Absorbs the error, publishes |
By default the exception propagates, unless a |
Conditions, eviction, put |
None: only read-through |
|
Composition with resilience patterns |
Explicit, in code: the cache is the outermost link |
Order between |
Rule of thumb: use @Cacheable for ordinary application caching and Spring’s rich cache features, described in
Caching: Spring Cache, Caffeine and Redis; use the Resilience4j Cache when you
specifically need the cache failure to be absorbed and observed in the same decorator chain as a breaker and a retry,
which is typical for a call to a remote, quota-limited API. Run: a @Cacheable bean on the cache book-prices loaded
once for two calls, on the same Boot context in which the Resilience4j-decorated ISBN lookup ran.
CacheRegistryStore: a cache as the registry’s store
CacheRegistryStore<E> implements the RegistryStore extension point (see
Registries, Configuration & Decorators)
on top of a JCache Cache<String, E>, with computeIfAbsent, find, remove, replace and values mapped to the JCache
operations. It lets a registry keep its entries in a cache instead of a ConcurrentHashMap. The caveats are
significant: registry entries (a CircuitBreaker, say) are live objects with state and threads, they are not
Serializable, so the JCache must be configured with setStoreByValue(false) (with the default it fails with
UncheckedIOException: Failed to serialize class …CircuitBreakerStateMachine, as the run showed), and a distributed
JCache cannot share the objects between JVMs anyway. In practice this is only useful for a local cache with custom
eviction.
javax.cache.Cache<String, CircuitBreaker> store = manager.createCache("cb-store",
new MutableConfiguration<String, CircuitBreaker>().setStoreByValue(false));
CircuitBreakerRegistry registry = CircuitBreakerRegistry.custom()
.withCircuitBreakerConfig(CircuitBreakerConfig.ofDefaults())
.withRegistryStore(new CacheRegistryStore<>(store))
.build();
Source: CacheRegistryStore at v2.4.0.
Cache pitfalls
| Pitfall | What to do |
|---|---|
Using the JCache reference implementation in production |
Use Caffeine, Ehcache or a distributed provider. |
Default store-by-value with non-serialisable values |
Make the value |
No expiry policy |
Set a |
The supplier running twice when it throws or returns |
Make it idempotent; return an empty object, not |
A slow distributed cache |
The error path is only for exceptions. Use short client timeouts or guard the cache with a breaker. |
Caching failures or fallback values |
Only successful results are stored. Do not decorate the fallback with the cache, or a degraded value is cached as if it were real. |
Expecting metrics in Actuator or Prometheus |
There are none; read |
Keys that omit an input of the supplier |
Build the key from every parameter that changes the result. |
Part 2: the Hedge module
What a hedged request is
Tail latency is the slowest few percent of calls. A service that answers in 20 ms nearly always, but in 800 ms one call in a hundred, makes a page that calls it ten times slow for one user in ten. Retry and TimeLimiter react to failure or to a timeout, both of which mean waiting for the long tail. A hedged request attacks it differently: if the first call has not answered after a short delay, send a second, identical call (to another replica, if the load balancer rotates), and use whichever answers first. The slow call is usually the one that hit a busy instance, and the second one rarely does.
The price is extra load: every hedge is a real request to the backend, so a hedge delay that is too short multiplies
traffic. And because the same request is sent twice, hedging suits only idempotent calls: reads, and writes that carry an
idempotency key. A hedged POST /orders without an idempotency key can create two orders. See
Resilience Fundamentals for idempotency as the
precondition for any repetition of a call.
resilience4j-hedge (copyright headers of 2021) implements this. There is no readme.io page for it; this section is
documented from the v2.4.0 source and its tests, and every claim was run.
Setup: it is outside the BOM
resilience4j-hedge is published to Maven Central as 2.4.0 like the other modules, but it is not in
resilience4j-bom and not a dependency of resilience4j-all. Declare it with an explicit version, like
resilience4j-spring-boot4:
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-hedge</artifactId>
<version>${resilience4j.version}</version>
</dependency>
Source: resilience4j-hedge at v2.4.0.
Run: the sample project resolved it with the explicit version, and the module depends only on resilience4j-core.
The module is also absent from every other module of the build: Decorators has no withHedge, and there is no
Spring, Micrometer, Reactor or Kotlin integration for it. The only mentions of hedge in the 2.4.0 source tree outside the
module are in settings.gradle.
The API: Hedge, HedgeConfig, HedgeRegistry
Hedge follows the pattern of the other modules: Hedge.ofDefaults(), Hedge.ofDefaults(name),
Hedge.of(config), Hedge.of(name, config), Hedge.of(name, config, tags) and the shortcut Hedge.of(Duration),
which creates a hedge with a fixed delay. HedgeRegistry (HedgeRegistry.builder()…build()) is an in-memory registry
with hedge(name), hedge(name, config), hedge(name, configName) and the other usual overloads, shared
configurations through addConfiguration, and the same semantics as the other registries: asking twice for the same
name returns the same instance. The name defaults to UNDEFINED.
The execution methods are two:
-
submit(Callable<T>, ExecutorService)returns aCompletableFuture<T>. The primary call runs on the executor you pass, and the hedged call runs on the hedge’s own scheduled executor. -
decorateCompletionStage(Supplier<CompletionStage<T>>)returns a supplier of the same shape. Both the primary and the hedged call use the same supplier, so it must start a new asynchronous operation each time it is called.
There is no blocking Supplier decoration: hedging needs asynchronous results, so a synchronous call is wrapped in a
Callable for submit.
HedgeConfig.Builder method |
Default | Meaning |
|---|---|---|
|
|
Fixed hedge delay. Selects the |
|
- |
Selects |
|
- |
Selects |
|
|
Core size of the hedge’s own scheduled thread pool ( |
|
none |
|
(internal) window size |
|
Size of the sliding window of the average supplier ( |
(internal) measure errors |
|
Whether failed primary calls count towards the average ( |
The Hedge.of(Duration) shortcut and HedgeConfig.custom().preconfiguredDuration(…) are the same thing. Run: the
default HedgeConfig.ofDefaults() printed PRECONFIGURED, a null cutoff, factor 0, window 100, measure-errors
true and concurrency 10.
There are no Spring Boot properties for hedging: no resilience4j.hedge prefix, no HedgeConfigCustomizer, no
auto-configured HedgeRegistry. Create the registry as a bean yourself, or the hedge directly.
The two duration suppliers
-
PRECONFIGUREDwaits a fixed time. Choose it close to the latency percentile you want to cut: if the p95 of the pricing call is 120 ms, a delay of about 120 ms hedges roughly 5% of the requests, which is only 5% extra load. -
AVERAGE_PLUSrecomputes the delay from observed primary call durations (successes, and failures whenshouldMeasureErrorsis true). Run: an empty window gave a delay ofPT0.02SforaveragePlusAmountDuration(20, true, 10), and after five calls of about 40 ms the delay settled around 69 ms. The percentage variant is not "average plus percent": the source computesaverage * percent / 100, soaveragePlusPercentageDuration(150, true)is 1.5 times the average (72 ms in the run) and50would hedge at half the average, which would hedge most calls.
A delay close to the average hedges about half of the calls, because by definition half are slower than the average.
That is rarely what you want; prefer a fixed delay near a high percentile, or an AVERAGE_PLUS amount that is a
multiple of the typical latency.
Semantics, from the source
HedgeImpl.decorateCaller is short, and its behaviour is worth stating precisely, because it differs from what the name suggests:
-
The primary call starts at once. A scheduled task completes a trigger after the hedge delay, and only then starts the hedged call.
-
The first call to complete wins, whether it succeeded or failed. If the primary fails before the delay, its exception is returned and no hedge is ever started (run: one attempt, a
CompletionException). If the hedged call fails while the primary is still running and would have succeeded, the hedge’s failure is what the caller gets (run: secondary failure counted,CompletionExceptionthrown, while a 300 ms primary was still pending). Hedging does not turn errors into successes; for that, put a Retry or a fallback around it. -
When the primary wins, the scheduled trigger is cancelled and the hedged future cancelled. When the hedge wins, the primary future is cancelled with
cancel(false): the primary call is not interrupted and keeps running to the end. In the Bookshelf run the slowpricing-servicerequest was still served after the caller had its answer, and the circuit breaker recorded both attempts. The loser’s work is wasted, not cancelled. -
Failures are rethrown as
(RuntimeException) throwable. Withsubmit, the callable’s exception is already wrapped in aRuntimeException, and the caller seesCompletionExceptionwith thatRuntimeExceptionas the cause. WithdecorateCompletionStageand a future that fails with a checked exception (aCompletableFuture.failedFuture(new IOException(…))), the cast fails: the caller receives anExecutionExceptionwhose cause is aClassCastException, not theIOException. Make decorated futures fail with unchecked exceptions. -
Hedge.ofDefaults()has no delay. Using it fails with aNullPointerExceptiononDuration.toNanos()the first time a call is made (run). Always set a duration.
Events and metrics
The hedge records the outcome of whichever call wins, in Hedge.Metrics and through Hedge.EventPublisher.
| Event class | getEventType() |
Published when |
|---|---|---|
|
|
The primary call won and succeeded. Carries |
|
|
The primary call won with an exception; also carries |
|
|
The hedged call won and succeeded. |
|
|
The hedged call won with an exception; also carries |
Consumers are registered with onPrimarySuccess, onPrimaryFailure, onSecondarySuccess and onSecondaryFailure.
getMetrics() exposes getCurrentHedgeDelay(), getPrimarySuccessCount(), getSecondarySuccessCount(),
getPrimaryFailureCount(), getSecondaryFailureCount() and getSecondaryPoolActiveCount(). The secondary success
ratio is the number to watch: a ratio near zero says the delay is too long to matter, a ratio near one says the delay is
below the normal latency and every call is being doubled. As with Cache, nothing is bridged to Micrometer or Actuator.
Running a hedge in the Bookshelf
The catalogue page reads the price of a book from the pricing-service, an idempotent GET. The test stubs a replica
that is slow (600 ms) for the first request and fast (20 ms) afterwards, and hedges after 100 ms. The circuit breaker is
placed inside the hedge, so it sees every attempt, the hedged one included.
public class HedgedPricing implements AutoCloseable {
private final RestClient http;
private final Hedge hedge;
private final CircuitBreaker breaker;
private final ExecutorService primary = Executors.newVirtualThreadPerTaskExecutor();
public HedgedPricing(RestClient.Builder builder, String baseUrl, Hedge hedge, CircuitBreaker breaker) {
this.http = builder.baseUrl(baseUrl).build();
this.hedge = hedge;
this.breaker = breaker;
}
public Money price(String isbn) {
Callable<Money> attempt = CircuitBreaker.decorateCallable(breaker,
() -> http.get().uri("/prices/{isbn}", isbn).retrieve().body(Money.class));
return hedge.submit(attempt, primary).join();
}
@Override
public void close() {
primary.shutdown();
}
}
Hedge hedge = Hedge.of("pricing", HedgeConfig.custom()
.preconfiguredDuration(Duration.ofMillis(100))
.withMaxConcurrency(4)
.build());
Source: derived from the v2.4.0 resilience4j-hedge source and tests,
there being no official documentation page. Run: after a warm-up call, price("978-0") returned in under 400 ms instead
of the 600 ms the slow replica took, with two requests to the stub, secondarySuccess 1 and primarySuccess 0; 800 ms
later the breaker had recorded two successful calls, the hedged one and the slow primary that finished in the background.
Composing a hedge with the other modules
Since Decorators has no hedge, composition is manual, and the position decides what each module measures.
-
Retry outside the hedge. Each retry attempt is a hedged call. This is the sensible pairing: the hedge cuts the latency of each attempt, and Retry recovers from failures, which the hedge does not.
-
CircuitBreaker, Bulkhead and RateLimiter inside the hedge (as in the example) count every real request, which is what protects the backend from the extra load. Outside the hedge they would count only the logical call and the hedge could exceed their limits.
-
TimeLimiter. Put a hard timeout on the whole hedged call (outside), and do not rely on the hedge to bound latency: it does not cancel the loser and it does not time out.
-
Cache in front. A cache in front of a hedge makes the hedge rarer, because only misses reach it.
The detailed rules for ordering are in Fallbacks & Composition.
Hedge pitfalls
| Pitfall | What to do |
|---|---|
Hedging a non-idempotent call |
Do not, unless the operation carries an idempotency key the server honours. |
|
There is no default delay: calls throw |
|
It is |
|
The builder copies the factor, window and cutoff but not the supplier type, so the copy reverts to |
A delay near the average latency |
About half the calls are doubled. Aim for a high percentile. |
Failing futures with checked exceptions |
The |
The loser keeps running |
The primary is not interrupted; make the backend call cheap to abandon, and size the backend for the extra load. |
Non-daemon threads per hedge instance |
Every |
Expecting hedge metrics in Actuator or Prometheus |
There are none; export |
What changed recently
-
2.0.0 removed Vavr from the core (the readme.io Cache example still uses
CheckedFunction1andTry). -
2.4.0 did not change the Cache or Hedge modules; the release notes are in the GitHub release (
RELEASENOTES.adocat the tag stops at 2.3.0). -
The readme.io Cache page is from version 0.x and recommends
compile 'org.ehcache:ehcache:3.7.1', a Gradle syntax Gradle removed; useimplementation. Theresilience4j-hedgemodule has no readme.io page at all and is absent from the BOM, fromresilience4j-alland from the readme.io module list.