Description
Summary
In dnsjava 3.x, when using the asynchronous ExtendedResolver.sendAsync() with a ResolverListener, timeout exceptions (IOException) are wrapped in a java.util.concurrent.CompletionException.
This behavior represents two issues for users:
-
A Breaking Change(backward incompatibility): It is a departure from dnsjava 1.x, where the listener would receive the raw IOException. This breaks compatibility for applications migrating from the previous version.
-
An Internal Inconsistency: It is inconsistent with the synchronous Resolver.send() method within dnsjava 3.x, which correctly unwraps the CompletionException and re-throws the original IOException.
This forces users of the ResolverListener pattern to add boilerplate code to manually unwrap the exception.
Steps to Reproduce
Create an ExtendedResolver pointing to a non-responsive IP address to guarantee a timeout.
Use the sendAsync(Message, ResolverListener) method.
In the ResolverListener.handleException() implementation, print the type of the received exception.
Conceptual Code Example:
Java
import org.xbill.DNS.*;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public class AsyncTimeoutIssue {
public static void main(String[] args) throws Exception {
// 1. Point to a blackhole address to ensure a timeout
ExtendedResolver resolver = new ExtendedResolver();
resolver.addServer(new InetSocketAddress("192.0.2.1", 53)); // RFC 5737 TEST-NET-1
resolver.setTimeout(2); // 2 seconds
Message query = Message.newQuery(Record.newRecord(Name.fromString("example.com."), Type.A, DClass.IN));
CountDownLatch latch = new CountDownLatch(1);
// 2. Use the async method with a listener
resolver.sendAsync(
query,
new ResolverListener() {
@Override
public void receiveMessage(Object id, Message m) {
System.out.println("Received message (should not happen)");
latch.countDown();
}
@Override
public void handleException(Object id, Exception ex) {
// 3. Observe the exception type
System.out.println("Exception received in listener: " + ex.getClass().getName());
if (ex instanceof CompletionException) {
System.out.println("Cause of CompletionException: " + ex.getCause().getClass().getName());
}
latch.countDown();
}
});
latch.await(5, TimeUnit.SECONDS);
}
}
Expected Behavior
To maintain backward compatibility with dnsjava 1.x and for API consistency within dnsjava 3.x itself, the ResolverListener.handleException() method should receive the original IOException that caused the failure, not the CompletionException wrapper.
The output of the example should show that the exception is java.io.IOException (or a subclass like org.xbill.DNS.exception.TimeoutException).
// Expected Output
Exception received in listener: org.xbill.DNS.exception.TimeoutException
Actual Behavior
The handleException method receives a java.util.concurrent.CompletionException, which wraps the underlying IOException.
// Actual Output
Exception received in listener: java.util.concurrent.CompletionException
Cause of CompletionException: org.xbill.DNS.exception.TimeoutException
Analysis and Proposed Solution
This behavior stems from the use of CompletionStage internally. While the synchronous CompletableFuture.get() path correctly unwraps the CompletionException into an ExecutionException (whose cause is then re-thrown), the async path that invokes the ResolverListener does not perform this unwrapping.
To improve API consistency, the logic that calls the ResolverListener should be updated to unwrap the CompletionException before invoking handleException(). This would align the async listener's behavior with the sync send() method and restore the expected behavior for legacy users.
A simple check and unwrap would look like this:
Java
// In the dnsjava internals that call the listener
void forwardExceptionToListener(Exception ex, ResolverListener listener, Object id) {
Throwable cause = ex;
if (ex instanceof CompletionException && ex.getCause() != null) {
cause = ex.getCause();
}
if (cause instanceof Exception) {
listener.handleException(id, (Exception) cause);
} else {
listener.handleException(id, new Exception("Wrapped non-exception throwable", cause));
}
}
Thank you for maintaining this essential library.