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).
// Gradleimplementation("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-resilience4jdeclares all four ascompileOnly.
API overview
| Adapter | Wraps | Returns on policy rejection | Returns on call failure |
|---|---|---|---|
DmxRetry | Retry | Failure(lastException) / Err(lastException) | same |
DmxCircuitBreaker | CircuitBreaker | Failure(CallNotPermittedException) / Err(...) | Failure(cause) / Err(cause) |
DmxRateLimiter | RateLimiter | Failure(RequestNotPermitted) / Err(...) | Failure(cause) / Err(cause) |
DmxBulkhead | Bulkhead | Failure(BulkheadFullException) / Err(...) | Failure(cause) / Err(cause) |
Each adapter offers three execution methods:
| Method | Return type | Policy rejection | Call throws checked | Call 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 |
DmxRetrydoes not haveexecuteResultTyped— retries always surface the last exception, soexecuteResultalready gives youResult<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 exhaustedTry<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 shapeResult<Response, Throwable> result2 = retry.executeResult(() -> paymentGateway.charge(cmd));
// Wrap an existing Retry configured elsewhere (e.g. via Resilience4J Registry)DmxRetry wrapping = DmxRetry.of(existingRetry);| Method | Returns | On 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 FailureTry<Item> result = cb.executeTry(() -> inventoryService.findItem(id));
result .onSuccess(item -> render(item)) .onFailure(ex -> renderUnavailable());
// executeResult — Err holds any ThrowableResult<Item, Throwable> result2 = cb.executeResult(() -> inventoryService.findItem(id));
// executeResultTyped — circuit-open becomes Err; call errors propagate as uncheckedResult<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 RegistryDmxCircuitBreaker wrapping = DmxCircuitBreaker.of(existingCircuitBreaker);| Method | Returns | Circuit open | Call throws checked | Call 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 FailureTry<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 RuntimeExceptionResult<SearchResult, RequestNotPermitted> result3 = rl.executeResultTyped(() -> searchApi.query(request));
result3.fold( ex -> renderRateLimited(), // RequestNotPermitted search -> render(search));
// Wrap an existing RateLimiter from a Resilience4J RegistryDmxRateLimiter wrapping = DmxRateLimiter.of(existingRateLimiter);| Method | Returns | Rate limit exceeded | Call throws checked | Call 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 FailureTry<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 RuntimeExceptionResult<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 RegistryDmxBulkhead wrapping = DmxBulkhead.of(existingBulkhead);| Method | Returns | Bulkhead full | Call throws checked | Call 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 version | Status |
|---|---|
| 2.0.2 | tested |
| 2.1.0 | tested |
| 2.2.0 | tested |
| 2.3.0 | tested |
| 2.4.0 | tested |
To test locally against a specific version:
./gradlew :resilience4j:test -Presilience4jVersion=2.2.0