CompletionStageUtils Error Handling: The Error-in-Fallback Bug#
Overview#
CompletionStageUtils is a utility class in resilience4j-core that provides recover() overloads for wrapping CompletionStage instances with exception-handling fallback logic. A behavioral gotcha exists in the typed recover() overloads (those that accept a Class<X> or List<Class<? extends Throwable>> argument): if the fallback (exceptionHandler) function itself throws a java.lang.Error, the returned CompletableFuture is never completed β it hangs forever.
Root Cause#
The two typed overloads β recover(CompletionStage, Class<X>, Function) and recover(CompletionStage, List<Class<?>>, Function) β each create a new CompletableFuture<T> promise and delegate to one of two private tryRecover helpers.
Both helpers guard the exceptionHandler.apply(...) call with a try/catch (Exception fallbackException) block . Because catch (Exception ...) does not catch java.lang.Error (which is a direct subclass of Throwable, not Exception), an Error thrown from the fallback escapes the catch block silently. At that point:
promise.complete(...)was never called (the fallback threw before it returned a value).promise.completeExceptionally(...)was never called (thecatchblock was bypassed).- The
promiseCompletableFutureis left in its pending state permanently.
Any code awaiting that future via .get(), .join(), or chained callbacks will block or never run.
Contrast: The Untyped Overload Is Safe#
The simplest recover overload delegates directly to CompletionStage.exceptionally(exceptionHandler). The JDK's own exceptionally implementation propagates any Throwable thrown by the handler as the exceptional completion of the returned stage, so no future is left dangling.
Affected Overloads#
| Overload | Safe? | Notes |
|---|---|---|
recover(stage, Function) | β | Uses CompletionStage.exceptionally |
recover(stage, Class<X>, Function) | β | tryRecover catches only Exception |
recover(stage, List<Class<?>>, Function) | β | tryRecover catches only Exception |
recover(stage, Predicate, UnaryOperator) | β | Uses thenApply; no manual promise |
| Supplier-wrapping variants of the above | Inherits the above behavior for the underlying delegate |
The two Supplier<CompletionStage<T>> typed overloads simply delegate to the affected CompletionStage-accepting overloads, so they are equally affected.
Fix Pattern#
The fix is to widen the catch clause in both tryRecover helpers from catch (Exception ...) to catch (Throwable ...):
// Before (broken for Error):
try {
promise.complete(exceptionHandler.apply(throwable));
} catch (Exception fallbackException) {
promise.completeExceptionally(fallbackException);
}
// After (correct):
try {
promise.complete(exceptionHandler.apply(throwable));
} catch (Throwable fallbackThrowable) {
promise.completeExceptionally(fallbackThrowable);
}
This ensures the promise is always completed β either normally, or exceptionally with whatever the fallback threw β regardless of whether the fallback raises a checked exception, an unchecked RuntimeException, or an Error (e.g., OutOfMemoryError, AssertionError, StackOverflowError).
Where to Look#
- Source file:
CompletionStageUtils.java - List-typed
tryRecover: lines 94β106 - Single-type
tryRecover: lines 108β120 - Affected public typed overloads: lines 52β67 and lines 77β92