Resilience4J Integration

The fun-resilience4j module provides dmx-fun adapters for the four core Resilience4J patterns: Retry, CircuitBreaker, RateLimiter, and Bulkhead. It is an optional dependency — the core fun library has no runtime dependency on Resilience4J.

Each adapter wraps a native Resilience4J instance. You configure the policy using the standard Resilience4J API and the adapter takes care of capturing the outcome as a dmx-fun type so you never need to catch exceptions in your business logic.

Adding the dependency

fun-resilience4j declares all four Resilience4J artifacts as compileOnly. You must add the artifacts you actually use explicitly to your classpath. Any version from 2.0.2 through 2.4.x is supported (see version compatibility below).

// Gradle
implementation("codes.domix:fun-resilience4j:LATEST_VERSION")
// Resilience4J — bring your own version (tested: 2.0.2–2.4.0)
implementation("io.github.resilience4j:resilience4j-retry:2.2.0")
implementation("io.github.resilience4j:resilience4j-circuitbreaker:2.2.0")
implementation("io.github.resilience4j:resilience4j-ratelimiter:2.2.0")
implementation("io.github.resilience4j:resilience4j-bulkhead:2.2.0")
<!-- Maven -->
<dependency>
<groupId>codes.domix</groupId>
<artifactId>fun-resilience4j</artifactId>
<version>LATEST_VERSION</version>
</dependency>
<!-- Resilience4J — bring your own version (tested: 2.0.2–2.4.0) -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-retry</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-circuitbreaker</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-ratelimiter</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-bulkhead</artifactId>
<version>2.2.0</version>
</dependency>

Add only the Resilience4J artifacts you actually use — fun-resilience4j declares all four as compileOnly.

API overview

AdapterWrapsReturns on policy rejectionReturns on call failure
DmxRetryRetryFailure(lastException) / Err(lastException)same
DmxCircuitBreakerCircuitBreakerFailure(CallNotPermittedException) / Err(...)Failure(cause) / Err(cause)
DmxRateLimiterRateLimiterFailure(RequestNotPermitted) / Err(...)Failure(cause) / Err(cause)
DmxBulkheadBulkheadFailure(BulkheadFullException) / Err(...)Failure(cause) / Err(cause)

Each adapter offers three execution methods:

MethodReturn typePolicy rejectionCall throws checkedCall throws unchecked
executeTry(supplier)Try<V>Failure(ex)Failure(cause)Failure(cause)
executeResult(supplier)Result<V, Throwable>Err(ex)Err(cause)Err(cause)
executeResultTyped(supplier)Result<V, SpecificException>Err(specificEx)throw new RuntimeException(cause)rethrows as-is

DmxRetry does not have executeResultTyped — retries always surface the last exception, so executeResult already gives you Result<V, Throwable>.

Choose executeTry when you want a flat failure channel and the caller will just log or recover. Choose executeResultTyped when you need to branch specifically on a policy rejection (e.g. show a “service unavailable” page on circuit open) while letting genuine call errors propagate.

DmxRetry

Executes a supplier through a retry policy. On every attempt that throws, Resilience4J waits and retries according to the RetryConfig. The adapter captures the final outcome as a Try or Result so your code stays exception-free.

import dmx.fun.Result;
import dmx.fun.Try;
import dmx.fun.resilience4j.DmxRetry;
import io.github.resilience4j.retry.RetryConfig;
import java.io.IOException;
import java.time.Duration;
RetryConfig config = RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(200))
.retryOnException(IOException.class::isInstance)
.build();
DmxRetry retry = DmxRetry.of("payment-gateway", config);
// Returns Try — failure holds the last exception if all attempts are exhausted
Try<Response> result = retry.executeTry(() -> paymentGateway.charge(cmd));
result
.onSuccess(r -> log.info("Charged: {}", r.transactionId()))
.onFailure(ex -> log.error("All retries failed", ex));
// Returns Result<V, Throwable> — same semantics, Result shape
Result<Response, Throwable> result2 = retry.executeResult(() -> paymentGateway.charge(cmd));
// Wrap an existing Retry configured elsewhere (e.g. via Resilience4J Registry)
DmxRetry wrapping = DmxRetry.of(existingRetry);
MethodReturnsOn last failure
executeTry(supplier)Try<V>Failure(lastException)
executeResult(supplier)Result<V, Throwable>Err(lastException)

DmxCircuitBreaker

Executes a supplier through a circuit breaker. When the circuit is open, calls are rejected immediately with CallNotPermittedException — no downstream traffic is issued. executeTry and executeResult capture the rejection like any other failure; executeResultTyped surfaces it as a distinct typed error so callers can branch on it.

import dmx.fun.Result;
import dmx.fun.Try;
import dmx.fun.resilience4j.DmxCircuitBreaker;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import java.time.Duration;
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowSize(10)
.build();
DmxCircuitBreaker cb = DmxCircuitBreaker.of("inventory", config);
// executeTry — all failures (circuit open or call error) land in Failure
Try<Item> result = cb.executeTry(() -> inventoryService.findItem(id));
result
.onSuccess(item -> render(item))
.onFailure(ex -> renderUnavailable());
// executeResult — Err holds any Throwable
Result<Item, Throwable> result2 = cb.executeResult(() -> inventoryService.findItem(id));
// executeResultTyped — circuit-open becomes Err; call errors propagate as unchecked
Result<Item, CallNotPermittedException> result3 =
cb.executeResultTyped(() -> inventoryService.findItem(id));
result3.fold(
ex -> renderCircuitOpen(), // CallNotPermittedException — circuit is open
item -> render(item)
);
// Wrap an existing CircuitBreaker from a Resilience4J Registry
DmxCircuitBreaker wrapping = DmxCircuitBreaker.of(existingCircuitBreaker);
MethodReturnsCircuit openCall throws checkedCall throws unchecked
executeTry(supplier)Try<V>Failure(CallNotPermittedException)Failure(cause)Failure(cause)
executeResult(supplier)Result<V, Throwable>Err(CallNotPermittedException)Err(cause)Err(cause)
executeResultTyped(supplier)Result<V, CallNotPermittedException>Err(CallNotPermittedException)throw new RuntimeException(cause)rethrows as-is

DmxRateLimiter

Limits the rate of calls to a downstream service. When the configured quota is exceeded, the supplier is not called and RequestNotPermitted is returned. Configure timeoutDuration(Duration.ZERO) to reject immediately rather than queuing.

import dmx.fun.Result;
import dmx.fun.Try;
import dmx.fun.resilience4j.DmxRateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import java.time.Duration;
RateLimiterConfig config = RateLimiterConfig.custom()
.limitForPeriod(100)
.limitRefreshPeriod(Duration.ofSeconds(1))
.timeoutDuration(Duration.ZERO) // fail immediately if limit exceeded
.build();
DmxRateLimiter rl = DmxRateLimiter.of("search-api", config);
// executeTry — rate limit exceeded lands in Failure
Try<SearchResult> result = rl.executeTry(() -> searchApi.query(request));
result
.onSuccess(r -> render(r))
.onFailure(ex -> {
if (ex instanceof RequestNotPermitted) renderRateLimited();
else renderError(ex);
});
// executeResult — rate limit exceeded lands in Err(RequestNotPermitted)
Result<SearchResult, Throwable> result2 = rl.executeResult(() -> searchApi.query(request));
// executeResultTyped — rate-limit rejection becomes Err; call errors propagate as unchecked
// Note: checked exceptions thrown by the supplier are wrapped in RuntimeException
Result<SearchResult, RequestNotPermitted> result3 =
rl.executeResultTyped(() -> searchApi.query(request));
result3.fold(
ex -> renderRateLimited(), // RequestNotPermitted
search -> render(search)
);
// Wrap an existing RateLimiter from a Resilience4J Registry
DmxRateLimiter wrapping = DmxRateLimiter.of(existingRateLimiter);
MethodReturnsRate limit exceededCall throws checkedCall throws unchecked
executeTry(supplier)Try<V>Failure(RequestNotPermitted)Failure(cause)Failure(cause)
executeResult(supplier)Result<V, Throwable>Err(RequestNotPermitted)Err(cause)Err(cause)
executeResultTyped(supplier)Result<V, RequestNotPermitted>Err(RequestNotPermitted)throw new RuntimeException(cause)rethrows as-is

DmxBulkhead

Limits the number of concurrent calls. When the bulkhead is full, additional callers are rejected immediately with BulkheadFullException (when maxWaitDuration is zero). Use a bulkhead to isolate a slow downstream dependency so that slow calls do not exhaust the caller’s thread pool.

import dmx.fun.Result;
import dmx.fun.Try;
import dmx.fun.resilience4j.DmxBulkhead;
import io.github.resilience4j.bulkhead.BulkheadConfig;
import io.github.resilience4j.bulkhead.BulkheadFullException;
import java.time.Duration;
BulkheadConfig config = BulkheadConfig.custom()
.maxConcurrentCalls(20)
.maxWaitDuration(Duration.ZERO) // reject immediately when full
.build();
DmxBulkhead bh = DmxBulkhead.of("pdf-renderer", config);
// executeTry — bulkhead full lands in Failure
Try<byte[]> result = bh.executeTry(() -> pdfRenderer.render(document));
result
.onSuccess(pdf -> respond(pdf))
.onFailure(ex -> respondBusy());
// executeResult — bulkhead full lands in Err(BulkheadFullException)
Result<byte[], Throwable> result2 = bh.executeResult(() -> pdfRenderer.render(document));
// executeResultTyped — bulkhead full becomes Err; call errors propagate as unchecked
// Note: checked exceptions thrown by the supplier are wrapped in RuntimeException
Result<byte[], BulkheadFullException> result3 =
bh.executeResultTyped(() -> pdfRenderer.render(document));
result3.fold(
ex -> respondBusy(), // BulkheadFullException — too many concurrent calls
pdf -> respond(pdf)
);
// Wrap an existing Bulkhead from a Resilience4J Registry
DmxBulkhead wrapping = DmxBulkhead.of(existingBulkhead);
MethodReturnsBulkhead fullCall throws checkedCall throws unchecked
executeTry(supplier)Try<V>Failure(BulkheadFullException)Failure(cause)Failure(cause)
executeResult(supplier)Result<V, Throwable>Err(BulkheadFullException)Err(cause)Err(cause)
executeResultTyped(supplier)Result<V, BulkheadFullException>Err(BulkheadFullException)throw new RuntimeException(cause)rethrows as-is

Composing patterns

Resilience4J recommends layering patterns in this order (outer to inner): Bulkhead → RateLimiter → CircuitBreaker → Retry → Supplier.

With dmx-fun adapters, composition is straightforward — each adapter’s supplier can call the next adapter:

Result<Response, Throwable> result = bulkhead.executeResult(() ->
rateLimiter.executeTry(() ->
circuitBreaker.executeTry(() ->
retry.executeTry(() -> downstream.call()).getOrThrow()
).getOrThrow()
).getOrThrow()
);

Use getOrThrow() on Try at each inner layer to surface failures upward so the outer adapters record them correctly (circuit breaker failure-rate counting, etc.).

Real-world example

A product catalog service that layers all four patterns:

  • Rate limiter caps search API calls to 50 per second.
  • Bulkhead limits concurrent searches to 10.
  • Circuit breaker trips after 50% failures in the last 20 product fetches.
  • Retry attempts each product fetch up to 3 times before the circuit breaker sees the result.
import dmx.fun.Option;
import dmx.fun.Result;
import dmx.fun.Try;
import dmx.fun.resilience4j.DmxBulkhead;
import dmx.fun.resilience4j.DmxCircuitBreaker;
import dmx.fun.resilience4j.DmxRateLimiter;
import dmx.fun.resilience4j.DmxRetry;
import io.github.resilience4j.bulkhead.BulkheadConfig;
import io.github.resilience4j.bulkhead.BulkheadFullException;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import io.github.resilience4j.retry.RetryConfig;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* A product catalog service that uses all four Resilience4J patterns together.
*
* Search calls are rate-limited (external API quota) and bulkheaded (isolate
* concurrent load). Individual product fetches go through a circuit breaker with
* a retry layer so transient errors are handled before the circuit trips.
*/
public class ProductCatalogService {
private final DmxRateLimiter searchRl;
private final DmxBulkhead searchBh;
private final DmxCircuitBreaker productCb;
private final DmxRetry productRetry;
private final ProductApiClient api;
private final Map<String, ProductPage> cache;
public ProductCatalogService(ProductApiClient api) {
this.api = api;
this.cache = new java.util.HashMap<>();
this.searchRl = DmxRateLimiter.of("search",
RateLimiterConfig.custom()
.limitForPeriod(50)
.limitRefreshPeriod(Duration.ofSeconds(1))
.timeoutDuration(Duration.ZERO)
.build());
this.searchBh = DmxBulkhead.of("search",
BulkheadConfig.custom()
.maxConcurrentCalls(10)
.maxWaitDuration(Duration.ZERO)
.build());
this.productCb = DmxCircuitBreaker.of("product",
CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowSize(20)
.build());
this.productRetry = DmxRetry.of("product",
RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(100))
.retryOnException(IOException.class::isInstance)
.build());
}
/**
* Searches the catalog. Rate-limited to 50 req/s; max 10 concurrent.
* Returns an empty list only when rejected by resilience policies
* (rate limit exceeded or bulkhead full). Other failures are propagated.
*/
public List<ProductSummary> search(String query) {
return searchRl.executeTry(() ->
searchBh.executeTry(() -> api.search(query))
.recover(BulkheadFullException.class, ex -> List.of())
.getOrThrow(cause ->
cause instanceof RuntimeException re ? re : new RuntimeException(cause))
)
.recover(RequestNotPermitted.class, ex -> List.of())
.getOrThrow(cause ->
cause instanceof RuntimeException re ? re : new RuntimeException(cause));
}
/**
* Fetches a single product. Retried up to 3 times on IOException;
* circuit breaker trips after 50% failures in the last 20 calls.
*
* Returns Ok(product), Err(CallNotPermittedException) when open,
* or Err(lastException) after all retries are exhausted.
*/
public Result<Product, Throwable> findProduct(String id) {
return productCb.executeResult(() ->
productRetry.executeTry(() -> api.fetchProduct(id))
.getOrThrow() // surface failure so circuit breaker records it
);
}
/**
* Renders a product page, falling back to a cached copy on circuit open
* and returning an empty Option when nothing is available at all.
*/
public Option<ProductPage> productPage(String id) {
return productCb.executeResultTyped(() ->
productRetry.executeTry(() -> api.fetchProduct(id)).getOrThrow()
)
.fold(
(CallNotPermittedException ex) -> Option.ofNullable(cache.get(id)), // circuit open → cached page
product -> Option.some(buildPage(product))
);
}
}

Version compatibility

The module is tested in CI against the following Resilience4J versions on every pull request that touches the resilience4j/ module:

Resilience4J versionStatus
2.0.2tested
2.1.0tested
2.2.0tested
2.3.0tested
2.4.0tested

To test locally against a specific version:

Terminal window
./gradlew :resilience4j:test -Presilience4jVersion=2.2.0