Saturday, October 11, 2025

Exception-retry: A voyage with Resilience4j, Vavr and Lombok libraries


Elegant Functional Resilience method wrappers in action

Making exception handling and retries composable, declarative, and intuitive — the object-oriented way.


1. Overview

In a world of distributed microservices and unpredictable latency, resilience is not optional — it’s a design principle.

Yet, Java developers still fight repetitive try-catch blocks, tangled retry loops, and awkward checked exceptions.
Wouldn’t it be cleaner to express resilience as a fluent function transformation, like this?

function .errorMappedFunction(...) .retryFunction(retry) .tryWrap() .toEither();

That is the heart of Exception-Retry — a compact functional abstraction layer built atop:

  • Resilience4j for circuit breakers, bulkheads, and retry semantics

  • Vavr for functional containers (TryEither)

  • Lombok’s @ExtensionMethod to bring fluent, object-oriented syntax

Eg: RxFunction / RxSupplier / RxTry for composable, type-safe wrappers


2. The Problem

In most enterprise systems, exception management grows chaotic as systems scale:

  • Nested try-catch clutter

  • Loss of contextual meaning in exceptions

  • Ad-hoc retry loops with sleep logic

  • Repeated handling of “expected failures”

This not only bloats code but subtly hides business intent behind boilerplate.

Functional programming offers an elegant alternative — functions as first-class citizens, and transformations instead of side effects.

However, traditional Java APIs never bridged that with real-world operational constructs — until now.


3. The Functional Resilience Model

The Exception-Retry model treats every thread executable (FunctionSupplierBiFunctionCheckedFunction, Callable, Runnable etc.) as something you can transform.

Each transformation is a pure function-to-function mapping, composable and declarative:

PurposeExtension MethodEffect
Retry policy.retryFunction(retry)Adds retry semantics via Resilience4j
Exception mapping.errorMappedFunction(...)Replaces one exception type with another
Exception side-effect.errorConsumedSupplier(...)Logs or records exception occurrences
Safety wrapping.tryWrap()Wraps execution in a Vavr Try
Deterministic result.toEither()Converts to functional Either<L,R>

Each transformation produces a new function — immutably and predictably.
This makes resilience not an operational afterthought, but part of your domain logic.


4. Getting Started

<dependency> <groupId>io.github.venkateshamurthy</groupId> <artifactId>exception-retry</artifactId> <version>1.3</version> <!-- Use latest --> </dependency>

Enable the extension methods:

@ExtensionMethod({ io.github.venkateshamurthy.exceptional.RxFunction.class, io.github.venkateshamurthy.exceptional.RxSupplier.class, io.github.venkateshamurthy.exceptional.RxTry.class })

You now gain access to .retryFunction()errorMappedFunction()tryWrap(), etc. on standard FunctionSupplier, and Callable and others.


5. Configuring Retry and Delay Strategies

Retry configuration is the backbone of resilience.
You can tune interval strategiesmaximum attempts, and retryable exception types.

Retry retry = Retry.of("example", RetryConfig.custom() .intervalFunction(FIBONACCI.millis(1, 300)) .retryExceptions(IOException.class, SQLException.class) .maxAttempts(10) .build());

Available delay strategies:

StrategyBehaviorUse Case
FIBONACCIGentle ramp-upAPIs that recover gradually
EXPONENTIALRapid escalationTransient network failures
LINEARConstant slopeRate-limited endpoints
LOGARITHMICSlow startBootstrapping pipelines

6. Core Examples

🔹 Function Example: Error Mapping + Retry

Function<String, Integer> fn = toFunction(s -> { if (s == null) throw new NullPointerException(); if (s.equals("bad")) throw new UnsupportedOperationException(); return s.length(); }); Either<Throwable, Integer> result = fn .errorMappedFunction( UnsupportedOperationException.class, IllegalStateException::new, NullPointerException.class, IllegalArgumentException::new ) .retryFunction(retry) .tryWrap() .toEither() .apply("bad");

✅ Readable
✅ Declarative
✅ Functional


🔹 Supplier Example: Error Consuming + Retry

Supplier<Integer> s = toSupplier(() -> { String input = "hi"; if (input.length() < 3) throw new IllegalStateException("too short"); return input.length(); }); Supplier<Integer> safe = s .errorConsumedSupplier(IllegalStateException.class, ex -> log.warn("Handled: {}", ex.getMessage())) .retrySupplier(retry); Try<Integer> t = safe.tryWrap(); Either<Throwable, Integer> e = t.toEither();

This pattern records errors without interrupting flow, perfect for metrics or observability layers.


🔹 CheckedBiFunction Example: Multi-Exception Handling

CheckedBiFunction<String, String, Integer> f = toCheckedBiFunction((a, b) -> { if (a.equals(b)) throw new IOException("equal!"); if (a.isEmpty()) throw new SQLException("empty"); return (a + b).length(); }); CheckedBiFunction<String, String, Integer> resilient = f .errorConsumedCheckedBiFunction(SQLException.class, ex -> log.warn("SQL error")) .retryCheckedBiFunction(retry); Either<Throwable, Integer> out = resilient.tryWrap("a", "b").toEither();

Checked exceptions are first-class citizens here — no try/catch required.


7. The Pipeline in Action

Pipeline Flow:

CheckedBiFunctionerrorMappedCheckedBiFunction ↓ errorConsumedCheckedBiFunction ↓ retryCheckedBiFunction (Resilience4j) ↓ Try / Either ↓ Functional Result


8. Design Philosophy

8.1. From Procedural to Declarative Resilience

Most retry or error-handling frameworks still feel procedural:

"When this exception happens, do this, then that."

This library inverts that pattern.
You declare transformations on the callable itself — meaning that resilience logic sits next to business logic, not buried inside it.

It’s readable resilience, not hidden control flow.

8.2. First-Class Checked Functions

In the Java world, CheckedFunction and CheckedBiFunction are often neglected.
Yet, they reflect real operational concerns — IOExceptionSQLException, etc.
Here, they are elevated, wrapped, and retriable without try/catch gymnastics.

8.3. Composition Over Configuration

Each method returns a new function — pure and composable.
You can combine them like Lego bricks:

myFunction .errorMappedFunction(...) .bulkheadFunction(bh) .circuitBreakFunction(cb) .retryFunction(retry) .tryWrap();

The chain expresses intent, not infrastructure.

8.4. Observability as a First-Class Citizen

Because each wrapper integrates with Resilience4j’s metrics, you can export:

  • Number of retries

  • Failed vs successful calls

  • Circuit breaker transitions

You can also record errors at consumption points for telemetry or analytics.


9. Architecture and Engineering Insights

This section dives deeper for system designers and framework builders.

9.1. The Type System Advantage

The RxFunction and RxSupplier types maintain static type safety throughout transformations.
When you call:

Function<String, Integer> fn = ... fn.retryFunction(retry);

the return type remains Function<String, Integer>, so your downstream code remains untouched — a remarkable feat for decorator-style APIs.

9.2. Try and Either Integration

The library uses Vavr’s Try to model “safe execution” and Either for result duality (Left = failureRight = success).
This is crucial for functional APIs, where exceptions should never escape uncontrolled.

fn.tryWrap() .toEither() .peek(System.out::println) .peekLeft(Throwable::printStackTrace);

This idiom matches Scala’s Try and Kotlin’s Result.

9.3. From Functional to Reactive

Though not reactive per se, these wrappers compose perfectly into reactive frameworks (Project Reactor, RxJava, Mutiny).
Because they are pure functions, you can lift them into a reactive stream without blocking semantics.


10. Advanced Patterns

✅ Primary + Fallback with Individual Retry Policies

var primarySafe = primarySupplier.retrySupplier(primaryRetry); var fallbackSafe = fallbackSupplier.retrySupplier(fallbackRetry); var result = primarySafe.tryWrap() .recoverWith(err -> fallbackSafe.tryWrap()) .get();

✅ Bulkhead + Retry

var safe = toFunction(myFunction) .bulkheadFunction(bulkhead) .retryFunction(retry) .tryWrap();

✅ Circuit Breaker Integration

var circuitSafe = fn.circuitBreakFunction(circuitBreaker).retryFunction(retry);

Each transformation layer remains orthogonal — so you can reorder or remove them independently.


11. Complete Example

import lombok.experimental.ExtensionMethod; import io.github.resilience4j.retry.Retry; import io.github.resilience4j.retry.RetryConfig; import io.github.resilience4j.core.functions.CheckedBiFunction; import io.vavr.control.Either; import static io.github.venkateshamurthy.exceptional.Delayer.FIBONACCI; @ExtensionMethod({ io.github.venkateshamurthy.exceptional.RxFunction.class, io.github.venkateshamurthy.exceptional.RxTry.class }) public class FullExample { public static void main(String[] args) { Retry retry = Retry.of("demo", RetryConfig.custom() .intervalFunction(FIBONACCI.millis(10, 500)) .maxAttempts(5) .retryExceptions(java.io.IOException.class) .build()); CheckedBiFunction<String, String, Integer> f = toCheckedBiFunction((a, b) -> { if (a == null || b == null) throw new java.sql.SQLException("null"); if (a.equals(b)) throw new java.io.IOException("io"); return (a + b).length(); }); var resilient = f .errorConsumedCheckedBiFunction(java.sql.SQLException.class, ex -> System.out.println("SQL consumed")) .retryCheckedBiFunction(retry); Either<Throwable, Integer> out = resilient.tryWrap("hello", "world").toEither(); out.peek(System.out::println).peekLeft(e -> e.printStackTrace()); } }

12. References


Author

Venkatesha Murthy
Published October 2025

No comments:

Post a Comment