From d16b76fdef44ec724c6a51af913e6bb180bc52ba Mon Sep 17 00:00:00 2001 From: Markus Heiden Date: Sat, 10 Oct 2026 12:22:05 +0200 Subject: [PATCH 1/2] Complete client futures if the async callback throws JaxrsClientCallback and JaxwsClientCallback invoke the user's callback before completing their future. If the callback throws, the exception escapes and the future is never completed, so threads blocked in Future.get() hang forever. In the JAX-WS case ClientImpl additionally catches the exception and invokes the same callback a second time. Guard every callback invocation: - If the callback throws while handling a successful response, the future completes exceptionally with that exception, like the JAX-WS reference implementation does. - If the callback throws while handling a failure, cancellation or interruption, the original exception is kept and the callback's exception is attached as a suppressed exception. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0149ipFQyLFL9z5WdmhCRg3r --- .../apache/cxf/jaxws/JaxwsClientCallback.java | 140 ++++++++++-------- .../cxf/jaxws/JaxwsClientCallbackTest.java | 45 ++++++ .../cxf/jaxrs/client/JaxrsClientCallback.java | 36 ++++- .../jaxrs/client/JaxrsClientCallbackTest.java | 61 ++++++++ 4 files changed, 214 insertions(+), 68 deletions(-) diff --git a/rt/frontend/jaxws/src/main/java/org/apache/cxf/jaxws/JaxwsClientCallback.java b/rt/frontend/jaxws/src/main/java/org/apache/cxf/jaxws/JaxwsClientCallback.java index c73da2d9f3f..596fb010909 100644 --- a/rt/frontend/jaxws/src/main/java/org/apache/cxf/jaxws/JaxwsClientCallback.java +++ b/rt/frontend/jaxws/src/main/java/org/apache/cxf/jaxws/JaxwsClientCallback.java @@ -41,38 +41,47 @@ public void handleResponse(Map ctx, Object[] res) { // The handler has to be called *before* future completes if (handler != null) { - handler.handleResponse(new Response() { - protected boolean cancelled; - - public Map getContext() { - return context; + try { + handler.handleResponse(new Response() { + protected boolean cancelled; + + public Map getContext() { + return context; + } + + public boolean cancel(boolean mayInterruptIfRunning) { + cancelled = true; + return true; + } + + @SuppressWarnings("unchecked") + public T get() throws InterruptedException, ExecutionException { + return (T)res[0]; + } + + @SuppressWarnings("unchecked") + public T get(long timeout, TimeUnit unit) throws InterruptedException, + ExecutionException, TimeoutException { + return (T)res[0]; + } + + public boolean isCancelled() { + return cancelled; + } + + public boolean isDone() { + return true; + } + + }); + } catch (Throwable t) { + // The future has to complete even if the handler fails, otherwise waiting threads hang forever + delegate.completeExceptionally(t); + synchronized (this) { + notifyAll(); } - - public boolean cancel(boolean mayInterruptIfRunning) { - cancelled = true; - return true; - } - - @SuppressWarnings("unchecked") - public T get() throws InterruptedException, ExecutionException { - return (T)res[0]; - } - - @SuppressWarnings("unchecked") - public T get(long timeout, TimeUnit unit) throws InterruptedException, - ExecutionException, TimeoutException { - return (T)res[0]; - } - - public boolean isCancelled() { - return cancelled; - } - - public boolean isDone() { - return true; - } - - }); + return; + } } delegate.complete(res); @@ -86,41 +95,46 @@ public boolean isDone() { public void handleException(Map ctx, final Throwable ex) { context = ctx; + // The handler has to be called *before* future completes if (handler != null) { - handler.handleResponse(new Response() { - protected boolean cancelled; - - public Map getContext() { - return context; - } - - public boolean cancel(boolean mayInterruptIfRunning) { - cancelled = true; - return true; - } - - public T get() throws InterruptedException, ExecutionException { - throw new ExecutionException(ex); - } - - public T get(long timeout, TimeUnit unit) - throws InterruptedException, ExecutionException, TimeoutException { - - throw new ExecutionException(ex); - } - - public boolean isCancelled() { - return cancelled; - } - - public boolean isDone() { - return true; - } - - }); + try { + handler.handleResponse(new Response() { + protected boolean cancelled; + + public Map getContext() { + return context; + } + + public boolean cancel(boolean mayInterruptIfRunning) { + cancelled = true; + return true; + } + + public T get() throws InterruptedException, ExecutionException { + throw new ExecutionException(ex); + } + + public T get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + + throw new ExecutionException(ex); + } + + public boolean isCancelled() { + return cancelled; + } + + public boolean isDone() { + return true; + } + + }); + } catch (Throwable t) { + // The future has to complete even if the handler fails, otherwise waiting threads hang forever + ex.addSuppressed(t); + } } - // The handler has to be called *before* future completes delegate.completeExceptionally(mapThrowable(ex)); synchronized (this) { diff --git a/rt/frontend/jaxws/src/test/java/org/apache/cxf/jaxws/JaxwsClientCallbackTest.java b/rt/frontend/jaxws/src/test/java/org/apache/cxf/jaxws/JaxwsClientCallbackTest.java index 73380b40ceb..7d35086d923 100644 --- a/rt/frontend/jaxws/src/test/java/org/apache/cxf/jaxws/JaxwsClientCallbackTest.java +++ b/rt/frontend/jaxws/src/test/java/org/apache/cxf/jaxws/JaxwsClientCallbackTest.java @@ -37,6 +37,7 @@ import org.junit.Test; import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.sameInstance; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertThrows; @@ -151,6 +152,50 @@ public void testTimeout() throws Exception { callback.get(10, TimeUnit.MILLISECONDS); } + @Test + public void testHandleResponseCallbackThrows() throws Exception { + final RuntimeException handlerException = new RuntimeException("handler failed"); + useThrowingHandler(handlerException); + + final CyclicBarrier barrier = new CyclicBarrier(2); + Object[] result = new Object[0]; + schedule(barrier, () -> callback.handleResponse(ctx, result)); + barrier.await(5, TimeUnit.SECONDS); + + ExecutionException ex = assertThrows(ExecutionException.class, () -> callback.get(5, TimeUnit.SECONDS)); + assertThat(ex.getCause(), sameInstance(handlerException)); + assertThrows(ExecutionException.class, () -> callback.get()); + assertThat(callback.isCancelled(), equalTo(false)); + assertThat(callback.isDone(), equalTo(true)); + } + + @Test + public void testHandleExceptionCallbackThrows() throws Exception { + final RuntimeException handlerException = new RuntimeException("handler failed"); + useThrowingHandler(handlerException); + + final RuntimeException cause = new RuntimeException("invocation failed"); + final CyclicBarrier barrier = new CyclicBarrier(2); + schedule(barrier, () -> callback.handleException(ctx, cause)); + barrier.await(5, TimeUnit.SECONDS); + + ExecutionException ex = assertThrows(ExecutionException.class, () -> callback.get(5, TimeUnit.SECONDS)); + assertThat(ex.getCause(), sameInstance(cause)); + assertThat(cause.getSuppressed(), equalTo(new Throwable[] {handlerException})); + assertThat(callback.isCancelled(), equalTo(false)); + assertThat(callback.isDone(), equalTo(true)); + } + + private void useThrowingHandler(final RuntimeException handlerException) { + handler = new AsyncHandler() { + @Override + public void handleResponse(Response res) { + throw handlerException; + } + }; + callback = new JaxwsClientCallback(handler, null); + } + private void schedule(CyclicBarrier barrier, Runnable runnable) { executor.schedule(() -> { barrier.await(5, TimeUnit.SECONDS); diff --git a/rt/rs/client/src/main/java/org/apache/cxf/jaxrs/client/JaxrsClientCallback.java b/rt/rs/client/src/main/java/org/apache/cxf/jaxrs/client/JaxrsClientCallback.java index 0532270a450..7deddd6b74e 100644 --- a/rt/rs/client/src/main/java/org/apache/cxf/jaxrs/client/JaxrsClientCallback.java +++ b/rt/rs/client/src/main/java/org/apache/cxf/jaxrs/client/JaxrsClientCallback.java @@ -59,7 +59,11 @@ public boolean cancel(boolean mayInterruptIfRunning) { if (!started) { // The handler has to be called *before* future completes if (handler != null) { - handler.failed(new CancellationException()); + try { + handler.failed(new CancellationException()); + } catch (Throwable t) { + // The future has to be cancelled even if the handler fails + } } delegate.cancel(mayInterruptIfRunning); @@ -83,7 +87,16 @@ public void handleResponse(Map ctx, Object[] res) { // The handler has to be called *before* future completes if (handler != null) { - handler.completed((T)res[0]); + try { + handler.completed((T)res[0]); + } catch (Throwable t) { + // The future has to complete even if the handler fails, otherwise waiting threads hang forever + delegate.completeExceptionally(t); + synchronized (this) { + notifyAll(); + } + return; + } } delegate.complete(res); @@ -98,7 +111,12 @@ public void handleException(Map ctx, final Throwable ex) { // The handler has to be called *before* future completes if (handler != null) { - handler.failed(ex); + try { + handler.failed(ex); + } catch (Throwable t) { + // The future has to complete even if the handler fails, otherwise waiting threads hang forever + ex.addSuppressed(t); + } } delegate.completeExceptionally(ex); @@ -131,7 +149,11 @@ public T get() throws InterruptedException, ExecutionException { return getObject(callback.get()[0]); } catch (InterruptedException ex) { if (callback.handler != null) { - callback.handler.failed(ex); + try { + callback.handler.failed(ex); + } catch (Throwable t) { + ex.addSuppressed(t); + } } throw ex; } @@ -142,7 +164,11 @@ public T get(long timeout, TimeUnit unit) throws InterruptedException, return getObject(callback.get(timeout, unit)[0]); } catch (InterruptedException ex) { if (callback.handler != null) { - callback.handler.failed(ex); + try { + callback.handler.failed(ex); + } catch (Throwable t) { + ex.addSuppressed(t); + } } throw ex; } diff --git a/rt/rs/client/src/test/java/org/apache/cxf/jaxrs/client/JaxrsClientCallbackTest.java b/rt/rs/client/src/test/java/org/apache/cxf/jaxrs/client/JaxrsClientCallbackTest.java index 4653414c22f..894e86b3184 100644 --- a/rt/rs/client/src/test/java/org/apache/cxf/jaxrs/client/JaxrsClientCallbackTest.java +++ b/rt/rs/client/src/test/java/org/apache/cxf/jaxrs/client/JaxrsClientCallbackTest.java @@ -41,6 +41,7 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.CoreMatchers.sameInstance; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertThrows; @@ -182,6 +183,66 @@ public void testTimeout() throws Exception { future.get(10, TimeUnit.MILLISECONDS); } + @Test + public void testHandleResponseCallbackThrows() throws Exception { + final RuntimeException handlerException = new RuntimeException("completed failed"); + useThrowingHandler(handlerException); + + final CyclicBarrier barrier = new CyclicBarrier(2); + Object[] result = new String[] {"results"}; + schedule(barrier, () -> callback.handleResponse(ctx, result)); + barrier.await(5, TimeUnit.SECONDS); + + ExecutionException ex = assertThrows(ExecutionException.class, () -> future.get(5, TimeUnit.SECONDS)); + assertThat(ex.getCause(), sameInstance(handlerException)); + assertThrows(ExecutionException.class, () -> future.get()); + assertThat(future.isCancelled(), equalTo(false)); + assertThat(future.isDone(), equalTo(true)); + } + + @Test + public void testHandleExceptionCallbackThrows() throws Exception { + final RuntimeException handlerException = new RuntimeException("failed failed"); + useThrowingHandler(handlerException); + + final RuntimeException cause = new RuntimeException("invocation failed"); + final CyclicBarrier barrier = new CyclicBarrier(2); + schedule(barrier, () -> callback.handleException(ctx, cause)); + barrier.await(5, TimeUnit.SECONDS); + + ExecutionException ex = assertThrows(ExecutionException.class, () -> future.get(5, TimeUnit.SECONDS)); + assertThat(ex.getCause(), sameInstance(cause)); + assertThat(cause.getSuppressed(), equalTo(new Throwable[] {handlerException})); + assertThat(future.isCancelled(), equalTo(false)); + assertThat(future.isDone(), equalTo(true)); + } + + @Test + public void testHandleCancellationCallbackThrows() throws Exception { + useThrowingHandler(new RuntimeException("failed failed")); + + assertThat(future.cancel(true), equalTo(true)); + assertThrows(InterruptedException.class, () -> future.get(10, TimeUnit.MILLISECONDS)); + assertThat(future.isCancelled(), equalTo(true)); + assertThat(future.isDone(), equalTo(true)); + } + + private void useThrowingHandler(final RuntimeException handlerException) { + handler = new InvocationCallback() { + @Override + public void failed(Throwable throwable) { + throw handlerException; + } + + @Override + public void completed(String response) { + throw handlerException; + } + }; + callback = new JaxrsClientCallback(handler, String.class, null); + future = (JaxrsResponseFuture)callback.createFuture(); + } + private void schedule(CyclicBarrier barrier, Runnable runnable) { executor.schedule(() -> { barrier.await(5, TimeUnit.SECONDS); From 69701c52a3b461eefe96731672625655228f674f Mon Sep 17 00:00:00 2001 From: Markus Heiden Date: Sat, 10 Oct 2026 17:15:52 +0200 Subject: [PATCH 2/2] Follow the callback contract instead of guarding handleResponse CXF callers invoke ClientCallback.handleException() if handleResponse() throws, so the callbacks don't need to guard the success path. Revert those guards and fix the call sites that don't follow the contract: ClientImpl's fault observer wrapper and WebClient's ClientAsyncResponseInterceptor. The failure paths stay guarded in the callbacks, because callers can't complete the future if handleException() throws. Don't add a callback exception as suppressed to itself, which would throw IllegalArgumentException if the callback rethrows the exception it was given. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ka6VjhpAcDrSERoiLP2Cxx --- .../org/apache/cxf/endpoint/ClientImpl.java | 6 +- .../apache/cxf/jaxws/JaxwsClientCallback.java | 65 +++++++++---------- .../cxf/jaxws/JaxwsClientCallbackTest.java | 10 ++- .../cxf/jaxrs/client/JaxrsClientCallback.java | 23 +++---- .../apache/cxf/jaxrs/client/WebClient.java | 15 ++++- .../jaxrs/client/JaxrsClientCallbackTest.java | 10 ++- .../systest/jaxrs/JAXRSAsyncClientTest.java | 22 +++++++ 7 files changed, 96 insertions(+), 55 deletions(-) diff --git a/core/src/main/java/org/apache/cxf/endpoint/ClientImpl.java b/core/src/main/java/org/apache/cxf/endpoint/ClientImpl.java index 17fdc79b400..35803e1c4f2 100644 --- a/core/src/main/java/org/apache/cxf/endpoint/ClientImpl.java +++ b/core/src/main/java/org/apache/cxf/endpoint/ClientImpl.java @@ -517,7 +517,11 @@ public void onMessage(Message message) { Map ctx = responseContext.get(Thread.currentThread()); List resList = CastUtils.cast(inMsg.getContent(List.class)); Object[] result = resList == null ? null : resList.toArray(); - callback.handleResponse(ctx, result); + try { + callback.handleResponse(ctx, result); + } catch (Throwable t) { + callback.handleException(ctx, t); + } return; } } diff --git a/rt/frontend/jaxws/src/main/java/org/apache/cxf/jaxws/JaxwsClientCallback.java b/rt/frontend/jaxws/src/main/java/org/apache/cxf/jaxws/JaxwsClientCallback.java index 596fb010909..bdae53f0214 100644 --- a/rt/frontend/jaxws/src/main/java/org/apache/cxf/jaxws/JaxwsClientCallback.java +++ b/rt/frontend/jaxws/src/main/java/org/apache/cxf/jaxws/JaxwsClientCallback.java @@ -41,47 +41,38 @@ public void handleResponse(Map ctx, Object[] res) { // The handler has to be called *before* future completes if (handler != null) { - try { - handler.handleResponse(new Response() { - protected boolean cancelled; - - public Map getContext() { - return context; - } - - public boolean cancel(boolean mayInterruptIfRunning) { - cancelled = true; - return true; - } + handler.handleResponse(new Response() { + protected boolean cancelled; + + public Map getContext() { + return context; + } - @SuppressWarnings("unchecked") - public T get() throws InterruptedException, ExecutionException { - return (T)res[0]; - } + public boolean cancel(boolean mayInterruptIfRunning) { + cancelled = true; + return true; + } - @SuppressWarnings("unchecked") - public T get(long timeout, TimeUnit unit) throws InterruptedException, - ExecutionException, TimeoutException { - return (T)res[0]; - } + @SuppressWarnings("unchecked") + public T get() throws InterruptedException, ExecutionException { + return (T)res[0]; + } - public boolean isCancelled() { - return cancelled; - } + @SuppressWarnings("unchecked") + public T get(long timeout, TimeUnit unit) throws InterruptedException, + ExecutionException, TimeoutException { + return (T)res[0]; + } - public boolean isDone() { - return true; - } + public boolean isCancelled() { + return cancelled; + } - }); - } catch (Throwable t) { - // The future has to complete even if the handler fails, otherwise waiting threads hang forever - delegate.completeExceptionally(t); - synchronized (this) { - notifyAll(); + public boolean isDone() { + return true; } - return; - } + + }); } delegate.complete(res); @@ -131,7 +122,9 @@ public boolean isDone() { }); } catch (Throwable t) { // The future has to complete even if the handler fails, otherwise waiting threads hang forever - ex.addSuppressed(t); + if (t != ex) { + ex.addSuppressed(t); + } } } diff --git a/rt/frontend/jaxws/src/test/java/org/apache/cxf/jaxws/JaxwsClientCallbackTest.java b/rt/frontend/jaxws/src/test/java/org/apache/cxf/jaxws/JaxwsClientCallbackTest.java index 7d35086d923..8bbfbe9c4b3 100644 --- a/rt/frontend/jaxws/src/test/java/org/apache/cxf/jaxws/JaxwsClientCallbackTest.java +++ b/rt/frontend/jaxws/src/test/java/org/apache/cxf/jaxws/JaxwsClientCallbackTest.java @@ -159,11 +159,19 @@ public void testHandleResponseCallbackThrows() throws Exception { final CyclicBarrier barrier = new CyclicBarrier(2); Object[] result = new Object[0]; - schedule(barrier, () -> callback.handleResponse(ctx, result)); + // Callers invoke handleException() if handleResponse() throws + schedule(barrier, () -> { + try { + callback.handleResponse(ctx, result); + } catch (Throwable t) { + callback.handleException(ctx, t); + } + }); barrier.await(5, TimeUnit.SECONDS); ExecutionException ex = assertThrows(ExecutionException.class, () -> callback.get(5, TimeUnit.SECONDS)); assertThat(ex.getCause(), sameInstance(handlerException)); + assertThat(handlerException.getSuppressed().length, equalTo(0)); assertThrows(ExecutionException.class, () -> callback.get()); assertThat(callback.isCancelled(), equalTo(false)); assertThat(callback.isDone(), equalTo(true)); diff --git a/rt/rs/client/src/main/java/org/apache/cxf/jaxrs/client/JaxrsClientCallback.java b/rt/rs/client/src/main/java/org/apache/cxf/jaxrs/client/JaxrsClientCallback.java index 7deddd6b74e..b1a590e299b 100644 --- a/rt/rs/client/src/main/java/org/apache/cxf/jaxrs/client/JaxrsClientCallback.java +++ b/rt/rs/client/src/main/java/org/apache/cxf/jaxrs/client/JaxrsClientCallback.java @@ -87,16 +87,7 @@ public void handleResponse(Map ctx, Object[] res) { // The handler has to be called *before* future completes if (handler != null) { - try { - handler.completed((T)res[0]); - } catch (Throwable t) { - // The future has to complete even if the handler fails, otherwise waiting threads hang forever - delegate.completeExceptionally(t); - synchronized (this) { - notifyAll(); - } - return; - } + handler.completed((T)res[0]); } delegate.complete(res); @@ -115,7 +106,9 @@ public void handleException(Map ctx, final Throwable ex) { handler.failed(ex); } catch (Throwable t) { // The future has to complete even if the handler fails, otherwise waiting threads hang forever - ex.addSuppressed(t); + if (t != ex) { + ex.addSuppressed(t); + } } } @@ -152,7 +145,9 @@ public T get() throws InterruptedException, ExecutionException { try { callback.handler.failed(ex); } catch (Throwable t) { - ex.addSuppressed(t); + if (t != ex) { + ex.addSuppressed(t); + } } } throw ex; @@ -167,7 +162,9 @@ public T get(long timeout, TimeUnit unit) throws InterruptedException, try { callback.handler.failed(ex); } catch (Throwable t) { - ex.addSuppressed(t); + if (t != ex) { + ex.addSuppressed(t); + } } } throw ex; diff --git a/rt/rs/client/src/main/java/org/apache/cxf/jaxrs/client/WebClient.java b/rt/rs/client/src/main/java/org/apache/cxf/jaxrs/client/WebClient.java index 74f79e11681..ef57df668a5 100644 --- a/rt/rs/client/src/main/java/org/apache/cxf/jaxrs/client/WebClient.java +++ b/rt/rs/client/src/main/java/org/apache/cxf/jaxrs/client/WebClient.java @@ -1033,12 +1033,21 @@ protected void doHandleAsyncResponse(Message message, Response r, JaxrsClientCal } } if (cb.getResponseClass() == null || Response.class.equals(cb.getResponseClass())) { - cb.handleResponse(message, new Object[] {r}); + try { + cb.handleResponse(message, new Object[] {r}); + } catch (Throwable t) { + cb.handleException(message, t); + } } else if (r.getStatus() >= 300) { cb.handleException(message, convertToWebApplicationException(r)); } else { - cb.handleResponse(message, new Object[] {r.getEntity()}); - closeAsyncResponseIfPossible(r, message, cb); + try { + cb.handleResponse(message, new Object[] {r.getEntity()}); + } catch (Throwable t) { + cb.handleException(message, t); + } finally { + closeAsyncResponseIfPossible(r, message, cb); + } } } } diff --git a/rt/rs/client/src/test/java/org/apache/cxf/jaxrs/client/JaxrsClientCallbackTest.java b/rt/rs/client/src/test/java/org/apache/cxf/jaxrs/client/JaxrsClientCallbackTest.java index 894e86b3184..94cfe6094d6 100644 --- a/rt/rs/client/src/test/java/org/apache/cxf/jaxrs/client/JaxrsClientCallbackTest.java +++ b/rt/rs/client/src/test/java/org/apache/cxf/jaxrs/client/JaxrsClientCallbackTest.java @@ -190,11 +190,19 @@ public void testHandleResponseCallbackThrows() throws Exception { final CyclicBarrier barrier = new CyclicBarrier(2); Object[] result = new String[] {"results"}; - schedule(barrier, () -> callback.handleResponse(ctx, result)); + // Callers invoke handleException() if handleResponse() throws + schedule(barrier, () -> { + try { + callback.handleResponse(ctx, result); + } catch (Throwable t) { + callback.handleException(ctx, t); + } + }); barrier.await(5, TimeUnit.SECONDS); ExecutionException ex = assertThrows(ExecutionException.class, () -> future.get(5, TimeUnit.SECONDS)); assertThat(ex.getCause(), sameInstance(handlerException)); + assertThat(handlerException.getSuppressed().length, equalTo(0)); assertThrows(ExecutionException.class, () -> future.get()); assertThat(future.isCancelled(), equalTo(false)); assertThat(future.isDone(), equalTo(true)); diff --git a/systests/jaxrs/src/test/java/org/apache/cxf/systest/jaxrs/JAXRSAsyncClientTest.java b/systests/jaxrs/src/test/java/org/apache/cxf/systest/jaxrs/JAXRSAsyncClientTest.java index f56a9ee2b3c..c45661e0c56 100644 --- a/systests/jaxrs/src/test/java/org/apache/cxf/systest/jaxrs/JAXRSAsyncClientTest.java +++ b/systests/jaxrs/src/test/java/org/apache/cxf/systest/jaxrs/JAXRSAsyncClientTest.java @@ -410,6 +410,28 @@ public void testGetBookAsync404Callback() throws Exception { wc.close(); } + @Test + public void testGetBookAsyncCallbackThrows() throws Exception { + String address = "http://localhost:" + PORT + "/bookstore/books/123"; + WebClient wc = createWebClient(address); + final RuntimeException handlerException = new RuntimeException("completed failed"); + InvocationCallback callback = new InvocationCallback() { + public void completed(Book response) { + throw handlerException; + } + + public void failed(Throwable error) { + } + }; + try { + wc.async().get(callback).get(10, TimeUnit.SECONDS); + fail("Exception expected"); + } catch (ExecutionException ex) { + assertTrue(ex.getCause() == handlerException); + } + wc.close(); + } + @Test public void testClientResponseFilter() throws Exception { final String address = "http://localhost:" + PORT + "/bookstore/books/wildcard";