Introduction
Recently, while working on a project, I encountered a defect related to JDK dynamic proxies and reflection-based AOP. Our project does not use traditional Spring AOP, but instead, leverages ReflectionUtils
to dynamically intercept method calls. This approach provides flexibility but also introduces challenges in exception handling.
Instead of using standard Spring AOP annotations, our system relies on manually implemented proxies that intercept method calls via reflection. This allows us to apply cross-cutting concerns dynamically but also alters how exceptions propagate through the system. The issue arose when a method threw an exception that was expected to be caught at a higher level, but instead, it was wrapped as an UndeclaredThrowableException
. This behavior disrupted our business logic, as exception handling was a crucial part of it.
In this blog, I will walk through the root cause of this problem, analyze the underlying mechanism in Java's dynamic proxies, and discuss the solution we implemented.
The Problem
Consider the following simplified structure of our code:
public class ServiceA {
public void methodA() {
try {
try {
new ServiceB().methodB();
} catch (SpecialException ex) {
System.out.println("Caught SpecialException in methodA");
}
} catch (Exception ex) {
System.out.println("Caught Exception in methodA: " + ex.getClass().getName());
}
}
}
public class ServiceB {
public void methodB() throws SpecialException{
new ServiceC().methodC();
}
}
public class ServiceC {
public void methodC() throws SpecialException {
throw new SpecialException("This is a special exception");
}
}
public class SpecialException extends Exception {
public SpecialException(String message) {
super(message);
}
}
Expected vs. Actual Behavior
Expected:
-
methodC()
throwsSpecialException
. -
methodA()
should catchSpecialException
in the innertry-catch
block.
Actual:
- The exception bubbles up but is not caught in
methodA
. - Instead,
methodA
catchesUndeclaredThrowableException
in the outer catch block.
The Role of AOP and Dynamic Proxies
This problem occurred because methodB()
was being intercepted by an AOP proxy. When a method is proxied using JDK dynamic proxies, the call is routed through an invocation handler instead of being executed directly. This changes how exceptions are handled, as they are wrapped inside an InvocationTargetException
or UndeclaredThrowableException
before propagating. As a result, the normal exception-catching logic in the caller may not work as expected. The moment methodB()
was called, it was actually invoked via a JDK dynamic proxy, altering the exception propagation behavior.
Reflection-based AOP Example:
In our project, AOP was implemented using ReflectionUtils
. Below is the code example:
public class AopProxyHandler {
@Around("execution(* com.example.ServiceB.methodB(..))")
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
...
return ReflectionUtils.invokeMethod(method, target, args);
}
}
So the method call chain becomes:
-
ServiceA.methodA()
callsServiceB.methodB()
. -
ServiceB.methodB()
's proxy object invokesReflectionUtils.invokeMethod()
. - Inside
ReflectionUtils.invokeMethod()
,ServiceB.methodC()
is called. -
ServiceB.methodC()
throwsSpecialException
. -
ReflectionUtils.invokeMethod()
catchesSpecialException
, but it cannot directly throw this checked exception becauseinvoke()
does not declareSpecialException
.
Source Code
So let us see what ReflectionUtils.invokeMethod
does:
public abstract class ReflectionUtils {
@Nullable
public static Object invokeMthod(Method method, @Nullable Object target, @Nullable Object... args){
try{
return method.invoke(target,args);
} catch (Exception var4){
Exception ex = var4;
handleReflectionException(ex);
throw new IllegalStateException("should never get here");
}
}
}
Obviously, since we threw an exception before, the method will not continue to execute to method.invoke(target,args);
but will turn to the catch logic. Let's continue to explore the source code to see what happened:
public abstract class ReflectionUtils {
if (ex instanceof NoSuchMethodException) {
throw new IllegalStateException("Method not found: " + ex.getMessage());
} else if (ex instanceof IllegalAccessException) {
throw new IllegalStateException("Could not access method or field: " + ex.getMessage());
} else {
if (ex instanceof InvocationTargetException) {
handleInvocationTargetException((InvocationTargetException)ex);
}
if (ex instanceof RuntimeException) {
throw (RuntimeException)ex;
} else {
throw new UndeclaredThrowableException(ex);
}
}
}
public static void handleInvocationTargetException(InvocationTargetException ex){
rethrowRuntimeException(ex.getTargetException());
}
public static void rethrowRuntimeException(Throwable ex) {
if (ex instanceof RuntimeException) {
throw (RuntimeException)ex;
} else if (ex instanceof Error) {
throw (Error)ex;
} else {
throw new UndeclaredThrowableException(ex);
}
}
Since SpecialException
is a checked exception, it does not fall under RuntimeException
or Error
, leading to the final wrapping inside UndeclaredThrowableException
.
The Solution
1.Manually unwrap UndeclaredThrowableException
To resolve this issue, we modified our AOP logic to catch and unwrap the exception before it propagated further:
public class AopProxyHandler {
@Around("execution(* com.example.ServiceB.methodB(..))")
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
return ReflectionUtils.invokeMethod(method, target, args);
} catch (UndeclaredThrowableException ex) {
Throwable cause = ex.getCause();
if (cause instanceof SpecialException) {
throw (SpecialException) cause;
}
throw ex;
}
}
}
Now, when methodC()
throws SpecialException
, the AOP proxy unwraps it before returning control to methodA()
, allowing it to be caught as expected.
2.Use throws Throwable to be compatible with all exceptions
If you cannot modify the proxy logic and want methodA()
to handle SpecialException
, you can make methodA()
's catch
statement compatible with all exceptions:
public void methodA() {
try {
try {
methodB();
}
} catch (Throwable e) {
if (e instanceof UndeclaredThrowableException) {
Throwable cause = ((UndeclaredThrowableException) e).getUndeclaredThrowable();
if (cause instanceof SpecialException) {
System.out.println("Caught SpecialException from UndeclaredThrowableException: " + cause.getMessage());
}
} else {
System.out.println("Caught Exception: " + e.getMessage());
}
}
}
Key Takeaways
-
JDK Dynamic Proxies wrap undeclared checked exceptions in
UndeclaredThrowableException
. - Spring AOP with JDK proxies may alter exception propagation, affecting business logic.
- Unwrapping exceptions inside AOP ensures that they propagate correctly.
By understanding the internals of Java proxies and Spring AOP, we can avoid such issues in production code and ensure proper exception handling in our applications.
Hope this helps!
Top comments (0)