DEV Community

Thellu
Thellu

Posted on

Handling Exceptions in Reflection-Based AOP: The UndeclaredThrowableException Issue

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Expected vs. Actual Behavior

Expected:

  • methodC() throws SpecialException.
  • methodA() should catch SpecialException in the inner try-catch block.

Actual:

  • The exception bubbles up but is not caught in methodA.
  • Instead, methodA catches UndeclaredThrowableException 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);

    }
}
Enter fullscreen mode Exit fullscreen mode

So the method call chain becomes:

  • ServiceA.methodA() calls ServiceB.methodB().
  • ServiceB.methodB()'s proxy object invokes ReflectionUtils.invokeMethod().
  • Inside ReflectionUtils.invokeMethod(), ServiceB.methodC() is called.
  • ServiceB.methodC() throws SpecialException.
  • ReflectionUtils.invokeMethod() catches SpecialException, but it cannot directly throw this checked exception because invoke() does not declare SpecialException.

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");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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());
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. JDK Dynamic Proxies wrap undeclared checked exceptions in UndeclaredThrowableException.
  2. Spring AOP with JDK proxies may alter exception propagation, affecting business logic.
  3. 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)