Skip to content

Include the peer address in deferred connect errors - #115718

Open
valerypetrov wants to merge 29 commits into
ClickHouse:masterfrom
valerypetrov:fix/issue-48205
Open

Include the peer address in deferred connect errors#115718
valerypetrov wants to merge 29 commits into
ClickHouse:masterfrom
valerypetrov:fix/issue-48205

Conversation

@valerypetrov

@valerypetrov valerypetrov commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes: #48205

Changelog category (leave one):

  • Improvement

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Connect errors detected after a non-blocking connect (for example, while fetching a part from another replica) now name the peer: Connection refused, Network is unreachable, No route to host, and Host is down.

Documentation entry for user-facing changes

Poco::Net::SocketImpl::connect(address, timeout) reports connect failures in two ways: an immediate failure of ::connect already included address.toString(), but an error discovered after EINPROGRESS via poll() + SO_ERROR — the normal outcome when the peer is a remote host, as in replicated part fetches — reaches the caller through error(err), with no address argument at all. This is why logs showed bare Connection refused with no hint of which replica was unreachable. The message now becomes Connection refused: <ip>:<port>.

The address reported is the endpoint that was actually dialled: the address the resolver picked for this connection (so the resolved IP is shown, which also helps diagnose stale DNS entries, as noted in the issue), or the proxy endpoint when a proxy is in use and not bypassed. An immediate failure already carries the address, so those messages are left untouched rather than repeating it.

Scope. Connection refused is rethrown as a ConnectionRefusedException carrying the endpoint. The other deferred SO_ERROR failures lose the address on the same path and arrive as plain NetExceptions with Poco's bare text (Network is unreachable, No route to host, Host is down); those are restated as NetExceptions with the endpoint appended. NetException subclasses such as SSLException, and immediate failures whose text already names the peer, pass through untouched.

The integration test uses two instances because the deferred-error path only triggers for non-loopback connects, and covers both an IP literal and a hostname, so the resolved address is asserted rather than the name from the URL.


Workflow [PR]
Sync PR [sync-upstream/pr/115718]

Non-blocking connect failures discovered via poll()/SO_ERROR (the normal
path for refused connections to remote hosts, e.g. replica part fetches)
threw "Connection refused" without the peer address, unlike every other
failure path in SocketImpl::connect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexey-milovidov alexey-milovidov added the can be tested Allows running workflows for external contributors label Aug 21, 2026
@clickhouse-gh

clickhouse-gh Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [b5736ba]


AI Review

Summary

This PR moves deferred-connect endpoint attribution to the actual connect site, threads the HTTPS proxy tunnel through the caller-resolved proxy address, and mirrors the same endpoint naming in the native client's manual non-blocking connect loop. I did not find a remaining blocker or major correctness issue in the current code, and the earlier misattribution / dropped-endpoint findings are fixed in the current head.

Missing context / blind spots
  • ⚠️ I did not find a dedicated end-to-end regression that drives Connection::connectToAnyAddress's async_callback branch through a real deferred SO_ERROR timeout. throwSocketError mirrors SocketImpl::connect, so the implementation looks coherent, but that specific carrier is still justified more by code structure than by focused coverage.
Final Verdict

✅ No blocking review findings.

@clickhouse-gh clickhouse-gh Bot added the pr-improvement Pull request with some product improvements label Aug 21, 2026
@clickhouse-gh

clickhouse-gh Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing b5736baaa with master 9adeb11c7 (stripped binary size, per-symbol sizes and ThinLTO time; compile times per translation unit against the most recent warmup build that recompiled it).

✅ No significant changes.

Binary sizes
Binary Master PR Δ
programs/clickhouse-stripped 710.14 MiB 707.09 MiB -3.05 MiB (-0.43%)

Only the stripped binary is compared: the official master build keeps debug symbols while PR builds strip them, so the other binaries differ by construction.

Compile time of recompiled translation units

397 translation units recompiled, 2406 s compile time in total, 397 of them have a recent master baseline.

Job report

valerypetrov and others added 2 commits August 21, 2026 09:27
Adding the address inside Poco::Net::SocketImpl::connect also hit the
native client, whose own handler already appends the endpoint, so errors
read "Connection refused: host:port (host:port)." and
02550_benchmark_connections_credentials failed on the doubled text.

Restore SocketImpl and extend the message in the HTTP connection pool
instead: it covers the url() and part-fetch paths this is about, keeps the
exception type, and leaves client-facing messages as they were.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Poco::Exception's message setters are protected, so the previous version did
not compile. Rethrow ConnectionRefusedException carrying the endpoint instead.
Catching the NetException base would have been broader but would also swallow
SSLException, which the caller has to keep telling apart from a per-address
routing failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/Common/HTTPConnectionPool.cpp Outdated
valerypetrov and others added 2 commits August 21, 2026 15:44
The synchronous ECONNREFUSED path in Poco::Net::SocketImpl::connect throws
ConnectionRefusedException(address), so rewriting it produced
"Connection refused: host:port: host:port". Only the deferred poll()/SO_ERROR
path arrives with a bare message, so rethrow unchanged unless the message is
empty, and guard the no-duplication property in gtest_connection_pool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/Common/HTTPConnectionPool.cpp Outdated
Comment thread src/Common/HTTPConnectionPool.cpp Outdated
valerypetrov and others added 2 commits August 21, 2026 16:12
doConnect rebuilt the message from the request host, so a direct connect lost
the resolved peer IP the PR promises and a proxied one named the target instead
of the proxy it was really dialling. Use getResolvedAddress(), or the proxy
endpoint when the proxy is not bypassed, and share the bypass mirror with
prepareNewConnection. The other deferred SO_ERROR failures keep Poco's own text,
which the comment and the changelog now say instead of implying otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
@valerypetrov valerypetrov changed the title Include peer address in deferred connect errors Include the peer address in deferred "Connection refused" errors Aug 21, 2026
@valerypetrov valerypetrov changed the title Include the peer address in deferred "Connection refused" errors Include the peer address in deferred connect errors Aug 21, 2026
It is file-local; the missing prototype fails the -Wmissing-prototypes
build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/Common/HTTPConnectionPool.cpp Outdated
connectEndpoint() formats host:port through a bracket-aware helper for
both the proxy and direct branches, matching SocketAddress::toString();
the bare-text check uses contains() for the tidy build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/Common/HTTPConnectionPool.cpp Outdated
Session::reconnect resolves the proxy hostname inside connect(), so a deferred SO_ERROR failure reconstructed the endpoint from proxy_config.host and could not tell which of several proxy records refused. Stash the SocketAddress each connect attempt dials and prefer it in connectEndpoint(); cleared before every attempt so pre-connect DNS failures cannot leak a stale address.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Common/HTTPConnectionPool.cpp Outdated
HTTPSClientSession::connect ignores the SocketAddress reconnect() resolved on the tunnel branch: proxyConnect() built a fresh session from the proxy hostname and resolved it again, so with several A/AAAA records the deferred error could name a different record than the one actually dialled. proxyConnect() now takes the caller's resolved address and pins it on the inner session via setResolvedHost, so the tunnel dials exactly the address the stash recorded; proxyTunnel() and other callers keep the old behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread base/poco/NetSSL_OpenSSL/src/HTTPSClientSession.cpp
valerypetrov and others added 3 commits August 22, 2026 22:59
test_disk_over_web_server asserted the old bare 'Connection refused' text and now expects the address the refusal names. Adds the asked-for tunnel regression: an HTTPS request through an HTTP proxy whose resolvable name has a dead port, asserting the deferred error carries the resolved proxy address rather than the configured name, which a regression back to a bare proxyConnect() would report instead.
Two same-process resolutions of one hostname always agree, so a DNS-level test alone cannot tell 'reuse the caller's resolved address' from 'resolve the proxy again'. The new gtest makes the two disagree on purpose: the proxy hostname resolves to a live listener on 127.0.0.1 while the address passed to HTTPSClientSession::connect is 127.0.0.99, so honoring the pinned address is refused at TCP connect and a regression to bare proxyConnect() reaches the listener and fails in the CONNECT exchange or TLS handshake instead - the failure modes cannot be confused. The integration side adds the asked-for multi-record proxy hostname (two /etc/hosts A records, both with a dead port) asserting the deferred error names one concrete record and not the name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PooledConnection::connect() stashes dialled_address before any TCP
connect can fail, and both catch handlers in doConnect() only rewrap
errors that occur after such a connect attempt was made (a bare
ConnectionRefusedException, or a NetException carrying ENETUNREACH/
EHOSTUNREACH/EHOSTDOWN). The only earlier throw point, SocketAddress's
DNS resolution, raises DNSException subclasses that match neither
catch's type check, so dialled_address is always set by the time
connectEndpoint() runs and the proxy/resolved-host fallbacks - and the
formatHostAndPort() helper that served them - never execute. The
integration test added by this PR already asserts the fallback output
must never appear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread src/Common/HTTPConnectionPool.cpp
A borrowed connection keeps only a weak pointer to its endpoint pool, and
that pool can disappear while the connection is still in use: `dropCache()`
clears every endpoint, and the periodic `wipeExpired()` sweep erases pools
that hold no stored connection. `PooledConnection::reconnect` then dials on
its own instead of asking the pool for a fresh connection, and that fallback
went straight to `Session::reconnect`, so a deferred connect error came back
with Poco's bare text and no address - exactly what the rest of this change
set out to fix. Route the fallback through `doConnect` as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread src/Common/HTTPConnectionPool.cpp Outdated
Comment thread src/Common/tests/gtest_connection_pool.cpp Outdated
The rewrite of bare deferred connect errors lived only inside
PooledConnection::doConnect, so pool-backed sessions named their peer
while callers that construct a session directly and go through
HTTPClientSession::sendRequest -> reconnect -> connect - S3 credentials
renewal, JWT provider, BuzzHouse - kept emitting bare "Connection
refused" / "No route to host" without an address on deferred failures.
Move the rewrite into reconnect itself: it is the one place that knows
both branches of the address it dials, so every carrier of
HTTPClientSession and HTTPSClientSession benefits alike, and
PooledConnection::doConnect shrinks to reconnect plus socket inode
accounting.

Immediate failures already carry the peer and are left untouched, so no
message grows a repeated endpoint.
ProxyTunnelDialsTheCallerResolvedAddress used localhost as the proxy
name, which stays green if a dual-stack resolver picks ::1: the buggy
path then dials an unbound [::1] port, still raises
ConnectionRefusedException and passes. Configure the literal 127.0.0.1
where the listener is bound instead, so a regression always reaches the
live listener and fails later in the CONNECT exchange or TLS handshake.

Add DirectSessionReconnectNamesThePeer for sessions built without the
pool (S3 credentials, JWT provider, BuzzHouse): their sendRequest must
end up with the endpoint named exactly once, whether Poco reported it
natively on the immediate path or reconnect restored it on the deferred
one.
Comment thread src/Common/tests/gtest_connection_pool.cpp
Comment thread base/poco/Net/src/HTTPClientSession.cpp Outdated
@valerypetrov

Copy link
Copy Markdown
Contributor Author

Update on the review threads:

  • The deferred-connect error rewriting moved down into HTTPClientSession::reconnect itself, so direct-session callers (Credentials.cpp, JWTProvider.cpp, and BuzzHouse ExternalIntegrations) get the peer address in their errors too (9415ff5).
  • The tunnel test is pinned to literal 127.0.0.1 and now covers direct sessions (c367922), and it accepts the macOS timeout shape (2fe9c97).

All 24 ConnectionPool gtests pass locally.

valerypetrov and others added 2 commits August 27, 2026 22:58
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread base/poco/Net/src/HTTPClientSession.cpp Outdated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread base/poco/Net/src/HTTPClientSession.cpp Outdated
valerypetrov and others added 2 commits August 28, 2026 11:47
The reconnect-level rewrap could not tell a deferred connect failure from
the same bare shape raised after a successful dial: a proxy blackholing the
CONNECT exchange threw a bare TimeoutException from receiveBytes on the
inner tunnel session, the outer session's socket was still unattached, and
the failure was relabeled 'connect timed out: <proxy>'. SocketImpl::connect
now names the endpoint on its deferred SO_ERROR path (matching its other
two paths), and reconnect passes everything through untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread base/poco/Net/src/SocketImpl.cpp
valerypetrov and others added 4 commits August 28, 2026 16:19
The deferred and immediate connect paths now name the resolved address,
so the benchmark's wrapped message gained a middle segment; the test
normalizes it away and its reference stays unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Connection::connect walks several resolved addresses with its own
connectNB + poll + socketError loop, and reported the failure without
saying which address it was. It now passes the dialled address the same
way SocketImpl::connect does. The wrap below no longer appends the
connection description when the message already contains it, so a numeric
host stops rendering the endpoint twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
SocketImpl::error(int, const std::string &) is protected, so naming the
address from Connection.cpp's own non-blocking connect loop did not
compile. Add a public throwSocketError() that pairs the socketError()
read with that throw inside the class, keeping the protected overload
protected and dropping the static-accessed-through-instance NOLINT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Suppressing the trailing (host:port) when the message already named the
address broke 03025_clickhouse_host_env, which matches exactly that
suffix and passes on master. Naming the endpoint inside the Poco message
was the point of this PR, but the suffix is a stable part of the client's
connect error that callers match on, so it stays unconditional and the
mild repetition is accepted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread base/poco/Net/src/SocketImpl.cpp
POCO_ETIMEDOUT is the one case in SocketImpl::error's switch that throws
without arg, so routing the client's own non-blocking connect loop
through error(err, arg) lost the address on exactly the timeout it was
added to name. Mirror connect()'s deferred branch and synthesize the
TimeoutException with the endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

can be tested Allows running workflows for external contributors pr-improvement Pull request with some product improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Not host info for "Connection refused" error

2 participants