diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java b/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java index 2b6a840f5..a1d61177e 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java @@ -98,7 +98,12 @@ public void onSuccess(Channel channel, InetSocketAddress remoteAddress) { Request request = future.getTargetRequest(); Uri uri = request.getUri(); - timeoutsHolder.setResolvedRemoteAddress(remoteAddress); + // don't set a null resolved address - if the remoteAddress is null we keep + // the previously scheduled (possibly unresolved) address to avoid NPEs in + // timeout logging and keep useful diagnostic information + if (remoteAddress != null) { + timeoutsHolder.setResolvedRemoteAddress(remoteAddress); + } ProxyServer proxyServer = future.getProxyServer(); // For HTTPS proxies, establish SSL connection to the proxy server first diff --git a/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutTimerTask.java b/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutTimerTask.java index 3c9a3675e..b7e678fa8 100755 --- a/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutTimerTask.java +++ b/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutTimerTask.java @@ -57,6 +57,28 @@ public void clean() { void appendRemoteAddress(StringBuilder sb) { InetSocketAddress remoteAddress = timeoutsHolder.remoteAddress(); + + // Guard against null remoteAddress which can happen when the TimeoutsHolder + // was created without an original remote address (for example when using a + // pooled channel whose remoteAddress() returned null). In that case fall + // back to the URI host/port from the request to avoid a NPE and provide + // a useful diagnostic. + if (remoteAddress == null) { + if (nettyResponseFuture != null && nettyResponseFuture.getTargetRequest() != null) { + try { + String host = nettyResponseFuture.getTargetRequest().getUri().getHost(); + int port = nettyResponseFuture.getTargetRequest().getUri().getExplicitPort(); + sb.append(host == null ? "unknown" : host); + sb.append(':').append(port); + } catch (Exception ignored) { + sb.append("unknown:0"); + } + } else { + sb.append("unknown:0"); + } + return; + } + sb.append(remoteAddress.getHostString()); if (!remoteAddress.isUnresolved()) { sb.append('/').append(remoteAddress.getAddress().getHostAddress()); diff --git a/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java b/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java index acce84b6d..93f6b26a2 100755 --- a/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java +++ b/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java @@ -110,6 +110,12 @@ public void cancel() { } private Timeout newTimeout(TimerTask task, long delay) { - return requestSender.isClosed() ? null : nettyTimer.newTimeout(task, delay, TimeUnit.MILLISECONDS); + // requestSender or nettyTimer might be null in unit tests or in some edge + // cases where a channel's remote address wasn't available. In such cases + // avoid scheduling any timeouts rather than throwing a NPE. + if (requestSender == null || nettyTimer == null || requestSender.isClosed()) { + return null; + } + return nettyTimer.newTimeout(task, delay, TimeUnit.MILLISECONDS); } } diff --git a/client/src/test/java/org/asynchttpclient/netty/timeout/TimeoutTimerTaskTest.java b/client/src/test/java/org/asynchttpclient/netty/timeout/TimeoutTimerTaskTest.java new file mode 100644 index 000000000..2a5f5e205 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/netty/timeout/TimeoutTimerTaskTest.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2014-2025 AsyncHttpClient Project. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.asynchttpclient.netty.timeout; + +import org.asynchttpclient.AsyncCompletionHandler; +import org.asynchttpclient.DefaultAsyncHttpClientConfig; +import org.asynchttpclient.Request; +import org.asynchttpclient.RequestBuilder; +import org.asynchttpclient.channel.ChannelPoolPartitioning; +import org.asynchttpclient.netty.NettyResponseFuture; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class TimeoutTimerTaskTest { + + @Test + public void appendRemoteAddressShouldNotThrowWhenRemoteAddressIsNull() { + Request request = new RequestBuilder().setUrl("http://example.com:12345").build(); + NettyResponseFuture future = new NettyResponseFuture<>(request, new AsyncCompletionHandler() { + @Override + public Object onCompleted(org.asynchttpclient.Response response) throws Exception { + return null; + } + }, null, + 0, ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE, null, null); + + // create TimeoutsHolder without an original remote address + TimeoutsHolder timeoutsHolder = new TimeoutsHolder(null, future, null, new DefaultAsyncHttpClientConfig.Builder().build(), null); + + TimeoutTimerTask task = new TimeoutTimerTask(future, null, timeoutsHolder) { + @Override + public void run(io.netty.util.Timeout timeout) { + // no-op + } + }; + + StringBuilder sb = new StringBuilder(); + task.appendRemoteAddress(sb); + + // fallback should include URI host/port + assertTrue(sb.toString().contains("example.com:12345"), sb.toString()); + } + + @Test + public void appendRemoteAddressShouldPrintResolvedAddressIfAvailable() { + Request request = new RequestBuilder().setUrl("http://example.com:12345").build(); + NettyResponseFuture future = new NettyResponseFuture<>(request, new AsyncCompletionHandler() { + @Override + public Object onCompleted(org.asynchttpclient.Response response) throws Exception { + return null; + } + }, null, + 0, ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE, null, null); + + TimeoutsHolder timeoutsHolder = new TimeoutsHolder(null, future, null, new DefaultAsyncHttpClientConfig.Builder().build(), null); + + // set a resolved remote address + timeoutsHolder.setResolvedRemoteAddress(new InetSocketAddress("127.0.0.1", 8080)); + + TimeoutTimerTask task = new TimeoutTimerTask(future, null, timeoutsHolder) { + @Override + public void run(io.netty.util.Timeout timeout) { + // no-op + } + }; + + StringBuilder sb = new StringBuilder(); + task.appendRemoteAddress(sb); + assertTrue(sb.toString().contains(":8080"), sb.toString()); + } +}