🔴 The Error You're Seeing

Confirm this matches your console output. If it does, you're in the right place.

ERROR LOG// CAPTURED — OpenJDK 25 Temurin: InvocationHandler threw IOException; Service.run() declares nothing. // Thrown WITHOUT its own message — cause carries the payload (StackOverflowError-style paste). Exception in thread "main" java.lang.reflect.UndeclaredThrowableException at com.devinhyderabad.rpc.$Proxy0.run(Unknown Source) at com.devinhyderabad.rpc.ClientMain.main(ClientMain.java:19) Caused by: java.io.IOException: disk full at com.devinhyderabad.rpc.ClientMain$1.invoke(ClientMain.java:15) ... 2 more

⚡ Quick Fix Works 80% of the time

Declare the checked exception on the interface method (aligning the contract) — or translate handler exceptions into your own unchecked domain type before they reach the proxy.

interface Service { void run() throws IOException; // contract now matches reality } // or normalize inside the handler: catch (IOException e) { throw new ServiceFailure("run failed", e); }

🧠 Why this Happens

Tap to expand the deep technical explanation

Proxy dispatch routes interface calls through InvocationHandler.invoke, whose signature permits Throwable. But the CALLER compiled against the interface has no catch for undeclared checked types — letting IOException escape void run() would violate the language contract the client was built on. The proxy machinery therefore inspects the thrown throwable: RuntimeException and Error pass RAW (unchecked, always legal), while any CHECKED throwable the interface does not declare gets boxed into UndeclaredThrowableException — itself unchecked, with null message and the original in getCause(). RMI remoting, Spring AOP interceptors, and mocking libraries sit on this machinery, which is why the box appears so far from the crime scene.

The HITEC City Parking Spot Analogy:

The mailroom accepts parcels of ANY size, but your mailbox slot only fits letters — an oversized delivery comes back sealed in a flat-rate box stamped 'did not fit', original parcel inside.

🔁 How to Reproduce Confirm this is your error

Proxy an interface whose method declares nothing, have the handler throw IOException("disk full"), call the method. (Lab capture: OpenJDK 25 — messageless wrapper, payload in cause.)

🛠️ Solutions (5 Ways to Fix)

Solution 1✓ Most common cause

Declare the checked exception on the interface

👉 Use this when/if you own the interface and the failure genuinely belongs to its contract.

Adding throws IOException to the interface method lets the proxy deliver the raw exception legally — callers already handle it, stack traces stay unwrapped, and the boxing step disappears entirely. Contract and implementation align.

public interface RemoteStore { byte[] fetch(String key) throws IOException; // declared = deliverable }
Solution 2

Normalize handler exceptions into your own unchecked type

👉 Use this when/if the interface is fixed by an external spec and cannot grow throws clauses.

Catch checked exceptions INSIDE the InvocationHandler and rethrow as a domain-specific RuntimeException carrying the cause. Callers see one predictable family; logs keep full chains; the JDK's generic box never appears.

public Object invoke(Object p, Method m, Object[] a) throws Throwable { try { return backend.invoke(m.getName(), a); } catch (IOException | TimeoutException e) { throw new StoreUnavailableException(e); // unchecked, yours } }
Solution 3

Unwrap getCause() at consumer boundaries you cannot change

👉 Use this when/if third-party proxies already leak UndeclaredThrowableException into your handlers.

Where you cannot edit producer or interface, catch the box once at your adapter edge, inspect the cause type, and either recover specific cases or rethrow your own typed exception. Contain the JDK wrapper to a single translation point.

try { store.fetch(key); } catch (UndeclaredThrowableException e) { if (e.getCause() instanceof IOException io) { throw new CacheMiss(key, io); } throw e; }
Solution 4

Keep remote/AOP concerns off core interfaces via adapters

👉 Use this when/if interfaces accrete throws clauses purely to satisfy infrastructure layers.

Core interfaces describe DOMAIN operations; remote transport and cross-cutting concerns belong in adapter layers around them. When the interface stays free of infra exceptions, proxies have nothing undeclared to smuggle.

interface Catalog { Product find(String sku); } // pure domain class HttpCatalog implements Catalog { ... } // transport lives HERE Catalog proxied = withRetry(new HttpCatalog(client)); // decorator, not proxy-on-interface
Solution 5

Shrink the proxy surface altogether

👉 Use this when/if new code reaches for java.lang.reflect.Proxy out of habit.

Modern alternatives remove the dispatch layer where this exception lives: functional interfaces with explicit lambdas, HTTP-client interface bindings (spring-web @HttpExchange / Jakarta REST clients), or build-time code generation. Same ergonomics, zero runtime boxing surprises.

interface StoreApi { @Get("/items/{id}") CompletableFuture<Item> item(String id); // framework generates transport } StoreApi api = HttpServiceClient.create(StoreApi.class, baseUrl);

📋 Version Notes

Java 8

Boxing rule since 1.3 dynamic proxies; wrapper message historically null — still null today.

Java 11

Unchanged; RMI-era stacks remain the classic source.

Java 17

AOP and mocking frameworks keep it alive; semantics untouched.

Java 21

Virtual threads do not alter wrapping; interface-binding clients reduce exposure.

🛡️ How to Prevent This Next Time

Design interfaces whose throws clauses match realistic failures, normalize exceptions inside every InvocationHandler you write, wrap third-party proxies in thin adapters owning the unwrap policy, and prefer compile-time alternatives to reflective Proxy for new integrations.