Skip to content

Try the next resolved address if HTTP connect fails - #103786

Merged
george-larionov merged 48 commits into
masterfrom
http-connect-retry-on-net-exception
Jun 25, 2026
Merged

Try the next resolved address if HTTP connect fails#103786
george-larionov merged 48 commits into
masterfrom
http-connect-retry-on-net-exception

Conversation

@alexey-milovidov

@alexey-milovidov alexey-milovidov commented Apr 30, 2026

Copy link
Copy Markdown
Member

On macOS hosts where IPv6 is advertised by DNS but unreachable on the network, aiGenerate and other outbound HTTP requests fail outright when the resolver picks the AAAA address:

SELECT aiGenerate('ai_credentials', 'Write a one-line SQL joke')

Code: 1000. DB::Exception: Net Exception: No route to host: [2607:6bc0::10]:443. (POCO_EXCEPTION)
$ host api.anthropic.com
api.anthropic.com has address 160.79.104.10
api.anthropic.com has IPv6 address 2607:6bc0::10

HostResolver already records the failure via setFail and the next request will avoid the broken address, but the current request — the one the user sees — propagates the network error even though a working IPv4 address is known.

`EndpointConnectionPool::prepareNewConnection` now retries on Poco::Net::NetException with a fresh address from the resolver pool, bounded by the number of distinct addresses actually tried (cap of 4). Non-network errors (SSL, config) still propagate immediately, so they are not masked.

This is a generic improvement that applies to all HTTP outbound clients (S3, URL function, AI, Iceberg/Delta REST catalogs, etc.), not just the AI integration.

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

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

Fix outbound HTTP requests (e.g. `aiGenerate`, `url` table function, S3) failing with `No route to host` on hosts that advertise both IPv4 and IPv6 when only one address family is routable. The HTTP connection pool now falls back to the next resolved address when the first one fails with a network error, instead of propagating the error on the very first request.

Documentation entry for user-facing changes

  • Documentation is written (mandatory for new features)

Note

Medium Risk
Changes core HTTP connection establishment to retry across resolved IPs and tweaks resolver bookkeeping; regressions could affect outbound connectivity/error reporting, though attempts are bounded and covered by new tests.

Overview
Improves outbound HTTP robustness by retrying connection establishment against the next DNS-resolved address when the first connect attempt fails with network/timeout errors (bounded attempts, duplicate-address suppression), instead of failing the request immediately.

Adds resolver support for non-attempted addresses via HostResolver::Entry::setUnused() (prevents spurious success/failure accounting) and a test-only HostResolversPool::injectResolverForTest() to deterministically exercise multi-address retry behavior. Updates tests to cover direct retry success and to ensure proxy connect failures don’t pessimize target-host resolver state.

Reviewed by Cursor Bugbot for commit 03232bc. Bugbot is set up for automated code reviews on this repo. Configure here.

Version info

  • Merged into: 26.7.1.50

When DNS returns multiple addresses for a host (e.g. an A + AAAA pair),
ClickHouse picks one weighted-randomly via `HostResolver` and immediately
fails the request if connecting to that address throws a network error.
On networks with broken IPv6 routing — common on consumer macOS setups
where the AAAA record is reachable in DNS but `[2607:6bc0::10]:443` has
no route — every other request to such a host fails with `No route to
host` even though a working IPv4 address is known.

`HostResolver::Entry::setFail` already marks the bad address so the
*next* request avoids it, but the current request is the one the user
sees, so the AI functions (`aiGenerate` etc., which talk to
`api.anthropic.com`) and any other outbound HTTP client that picks the
unroutable family on the first try still propagate the error.

Make `EndpointConnectionPool::prepareNewConnection` retry on
`Poco::Net::NetException` with a fresh address from the resolver pool,
bounded by the number of distinct addresses we have actually tried (cap
of 4). Non-network errors still propagate immediately, so SSL / config
problems are not masked.
@clickhouse-gh

clickhouse-gh Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [04c29e8]

Summary:

job_name test_name status info comment
Stress test (amd_msan) FAIL
Cannot start clickhouse-server FAIL cidb IGNORED
Logical error: 'Unexpected exception in refresh scheduling' (STID: 2508-3e7b) FAIL cidb, issue ISSUE EXISTS
Check failed FAIL cidb IGNORED

AI Review

Summary

This PR makes direct outbound HTTP connection creation retry a different resolved address after address-level connect failures, while avoiding resolver attribution for proxy and post-connect failures. I found no remaining correctness, safety, or test-evidence issues after checking the current code, prior review threads, and the current CI report.

Final Verdict

Status: ✅ Approve

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Apr 30, 2026
Comment thread src/Common/HTTPConnectionPool.cpp Outdated
@alexey-milovidov
alexey-milovidov marked this pull request as draft April 30, 2026 14:16
Address review feedback on PR #103786: when the resolver returns an
address we have already tried in this `prepareNewConnection` call, the
loop now `continue`s instead of `break`ing. The outer `max_connect_attempts`
budget still bounds total work. The previous `break` could give up too
early because `HostResolver::selectBest` is weighted/random and, in some
cases (e.g. all known addresses become failed and the pool resets the
`failed` flag for further selection), can return a previously tried
address before all viable alternatives have been attempted. The duplicate
check now skips the address without consuming a network connect attempt.

The `chassert(last_net_error)` at the end of the function still holds:
the first iteration always inserts into the empty `tried_addresses` set
and either returns on success or sets `last_net_error` on failure.

Review: #103786
Comment thread src/Common/HTTPConnectionPool.cpp
…rations

Address review feedback on PR #103786: the previous implementation used a
single counter for both `resolver->resolve()` calls and actual network
connect attempts. When the resolver kept returning addresses already in
`tried_addresses` (which can happen because `HostResolver::selectBest` is
weighted/random and previously failed addresses can be reintroduced after
rebalancing), each duplicate `continue` consumed one slot of the
`max_connect_attempts` budget without making a real network attempt. This
could exhaust the budget and rethrow even when a working alternative
address had not yet been tried.

The fix splits the two concerns:

  - `connect_attempts` counts only real `doConnect` calls and is the
    primary stop condition (cap of 4, unchanged).
  - `max_resolve_iterations` is a separate safety cap on the outer loop
    (16) that guarantees termination if the resolver keeps yielding only
    duplicates.

Resolution is also moved before the `PooledConnection::create` so that a
duplicate-address `continue` no longer constructs and immediately
discards a `PooledConnection` object.

Review: #103786
Comment thread src/Common/HTTPConnectionPool.cpp
Comment thread src/Common/HTTPConnectionPool.cpp
…cate-address paths

Two corrections to `prepareNewConnection`'s retry-on-network-error logic:

* On the duplicate-address path (resolver returned an address we already
  tried in this loop), call `setFail` on the entry before `continue` so
  the `Entry` destructor does not record a spurious `setSuccess`. Without
  this, a duplicate hit resets `consecutive_fail_count` for an address
  that was just observed to fail, undermining the resolver's pessimization.
  `setFail` is idempotent for an already-failed record, so this does not
  double-bump the counter.

* Catch `Poco::Net::SSLException` ahead of `NetException` and propagate it
  immediately. `SSLException` derives from `NetException` in Poco, so the
  previous catch swept TLS/certificate failures into the per-address retry
  path and called `address.setFail` on them. Those errors are not
  per-address routing failures - they reproduce on any other resolved IP -
  so retrying poisons resolver statistics by banning healthy addresses on
  config-level problems.

Addresses bot review feedback on #103786.
Comment thread src/Common/HTTPConnectionPool.cpp Outdated
…ot attempted

`HTTPConnectionPool::prepareNewConnection` calls `address.setFail` on the
duplicate-address path purely to suppress the `Entry` destructor's `setSuccess`
callback. That has unwanted side effects: `HostResolver::setFail` always
increments `HostResolverFailed` and triggers `update` (a DNS refresh), even
when `Record::setFail` returned `false` because the record was already failed.
On every duplicate hit this overcounts failures and forces extra DNS work
without any real `doConnect` attempt.

Add `Entry::setUnused` for the case where an address was selected via
`resolve` but never actually used (so neither success nor failure should be
reported to the pool). Internally it just suppresses the destructor callback;
no pool-level state is touched. The duplicate-address branch now uses it.

Rename the internal `fail` flag to `skip_success_callback` to honestly
describe what it controls now that two different methods set it.

Addresses bot review feedback on #103786.
Comment thread src/Common/HTTPConnectionPool.cpp
…et on proxy-mode failures

In proxy mode, `Poco::Net::HTTPClientSession::reconnect` connects to
`_proxyConfig.host` and ignores `_resolved_host`, so retrying alternative
resolved target addresses does not change the network path. Disable the
resolved-address retry loop when a proxy is configured, and use `setUnused`
instead of `setFail` on connect failures so the failure - which belongs to
the proxy path - does not pessimize target-host resolver state or trigger
DNS refreshes for a host that was never actually contacted.

Add a `gtest_connection_pool` regression test that points the proxy at
`127.0.0.1:1` (TCPMUX, reserved, no listener), confirms the connect fails
with exactly one error increment, and verifies the target-host
`HostResolverFailed` counter is not bumped.
alexey-milovidov and others added 2 commits May 8, 2026 00:53
In the `SSLException` catch branch in `prepareNewConnection`, `address` is a
`HostResolver::Entry` RAII guard. The previous code rethrew without calling
`setFail` or `setUnused`, so `Entry::~Entry` ran `setSuccess` and recorded a
false success for an address whose TLS handshake had just failed. That silently
reset the resolver's `consecutive_fail_count` for that address and skewed
weighting after TLS/config failures.

Call `address.setUnused()` before the rethrow so this path has no resolver
side effects, matching the intent of the surrounding code: TLS/certificate
errors are config-level, not per-address routing failures, and must not
mutate the target-address resolver state in either direction.

Addresses bot review feedback on #103786.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/Common/HTTPConnectionPool.cpp
alexey-milovidov and others added 2 commits May 14, 2026 06:34
…tion` too

`Poco::Net::HTTPClientSession::reconnect` re-throws `Poco::TimeoutException`
on connect timeout, but the retry branch in `prepareNewConnection` only
caught `Poco::Net::NetException`. `Poco::TimeoutException` inherits from
`Poco::RuntimeException` (`base/poco/Foundation/include/Poco/Exception.h`),
not `Poco::Net::NetException`, so a timed-out first address fell into
`catch (...)` and was re-thrown immediately - the next resolved address
was never tried. On dual-stack hosts where IPv6 is advertised but the
network blackholes it, this still failed the first request despite the
PR contract.

Add an explicit `catch (const Poco::TimeoutException &)` next to the
`NetException` branch with the same `setFail`/`setUnused` policy.

Address review: #103786 (comment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/Common/HTTPConnectionPool.cpp Outdated
`Entry::setFail` calls `HostResolver::setFail`, which in turn calls
`HostResolver::update`. `update` can throw on DNS errors (`NXDOMAIN`,
empty result). In the `Poco::Net::NetException` and
`Poco::TimeoutException` retry branches of `prepareNewConnection`, the
`setFail` call ran before `last_net_error` was captured, so a transient
DNS failure during the bookkeeping aborted the retry loop and masked the
original connect exception - even when another already-resolved address
was still available.

Capture the connect exception into `last_net_error` first, then call
`setFail` inside a `try` block that logs and swallows any DNS-update
exception. The per-address failure has already been recorded in the
resolver's records before `update` runs, and `Entry` sets
`skip_success_callback` as the very first thing `setFail` does, so the
destructor will not record a spurious success either.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/Common/HTTPConnectionPool.cpp Outdated
…nect setup failures

Two fixes for the connect-time error attribution flagged by review:

1. In non-bypassed proxy mode `Poco::Net::HTTPClientSession::reconnect`
   connects to the proxy endpoint and the proxy resolves and reaches the
   target, so resolving the target host locally is useless and, for
   proxy-only/internal names, actively harmful: a local DNS error fails the
   request before the proxy is contacted. The proxy case now goes through a
   dedicated `prepareConnectionViaProxy` that never consults the target
   `HostResolver` (it leaves `_resolved_host` empty, which makes Poco use
   `_host` for the proxy request / `CONNECT` target). The now-dead
   `retry_resolved_addresses` branches are dropped from the direct-connect
   retry loop.

2. `Poco::Net::HTTPSession::connect` establishes the TCP connection first
   and only then applies local socket options (`setReceiveTimeout`,
   `setSendTimeout`, `setNoDelay`, the throttlers). Those `setsockopt` calls
   can throw `Poco::Net::NetException` of their own after the TCP connect has
   already succeeded; the retry handler would mis-attribute that to the
   resolved address and pessimize/retry a healthy address. The `NetException`
   handler now probes whether the socket reached a peer (`isConnectedToPeer`,
   via `getpeername`): a connected socket means the TCP connect succeeded, so
   the error is propagated without `setFail` and the address is left recorded
   as a success (it was reachable), mirroring the existing
   `applySocketBufferSizes` failure handling.

Test `ProxyConnectSkipsTargetResolution` verifies a proxied request to an
unresolvable target reaches the proxy connect instead of failing on local
DNS resolution of the target host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/Common/HTTPConnectionPool.cpp Outdated
alexey-milovidov and others added 3 commits June 19, 2026 15:56
`EndpointConnectionPool::prepareNewConnection` retries the next resolved
address when a connect attempt fails with a network error. Each attempt was
passing the caller's `connect_time` pointer directly into `doConnect`, and
`Session::reconnect` overwrites `*connect_time` with that single attempt's
duration. So a request that waited through a slow or blackholed address and
then succeeded quickly on a later one reported only the final fast attempt to
the caller's connect-latency metrics (e.g. the S3/Azure connect-time
histograms), understating the real connection-establishment time.

Accumulate the per-attempt connect durations into a local `total_connect_time`
and write the cumulative value back to `*connect_time` on every exit from the
retry loop - success or failure - via a scope guard. The per-attempt time is
captured into the accumulator however `doConnect` returns (the inner scope
guard fires while the stack unwinds, before the catch handlers run), so a
failed attempt's time is counted too. `connect_time == nullptr` callers are
unaffected: the cumulative value is only written through a non-null pointer.

Extend `RetriesNextAddressOnConnectFailure` to pass a non-null `connect_time`
and assert it is populated after a recovered retry, guarding the accumulation
plumbing against a regression that would leave it unwritten on the retry path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The test connected through a listener-less proxy port (`127.0.0.1:1`) and
asserted the resulting error text contains the proxy address, as a proxy for
"the proxy connect was reached and the unresolvable target host was not
resolved locally". That marker is unreliable: `Poco::Net::SocketImpl::connect`
with a timeout puts the socket into non-blocking mode, and a refused loopback
peer is reported via `SO_ERROR` after `poll` rather than synchronously. That
branch calls the address-less `error(int)` overload, which throws
`ConnectionRefusedException` with the message "Connection refused" and no
address, so the proxy address never appears in `displayText()` and the test
failed on every sanitizer build.

Match on the connection-error kind ("Connection refused") instead, accepting
the proxy address as well for the synchronous-failure path. The real invariant
- the unresolvable target host name must be absent from the error, proving no
local DNS lookup ran - is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Pushed b0684bd2c5f..30d24e82e5a (merged master, was 6 days / 2566 commits behind; the only auto-merged overlap in HTTPConnectionPool.cpp was master's unrelated ResourceGuardSessionDataHooks change).

Two changes:

  1. Fixed the failing ConnectionPoolTest.ProxyConnectSkipsTargetResolution unit test (red on every sanitizer build). Root cause: the test asserted the proxy address 127.0.0.1:1 appears in the connect-error text, but Poco::Net::SocketImpl::connect with a timeout uses a non-blocking socket and reports a refused loopback peer via SO_ERROR after poll, which calls the address-less error(int) overload → ConnectionRefusedException with message "Connection refused" and no address. The test now matches on the connection-error kind ("Connection refused", accepting the address too for the synchronous-failure path); the real invariant — the unresolvable target host name must be absent from the error — is unchanged.

  2. Addressed the AI Request-changes / Bugbot finding on connect_time. The retry loop now accumulates each attempt's connect duration into a cumulative total_connect_time and writes it back on every exit (success or failure) via a scope guard, so a slow/blackholed address followed by a fast success no longer understates connect latency in S3/Azure histograms. Failed attempts are counted (a per-attempt guard fires during unwinding before the catch handlers); connect_time == nullptr callers are unaffected. RetriesNextAddressOnConnectFailure extended to assert connect_time is populated on the retry path.

The remaining performance-comparison failures (array_reduce, hash_table_sizes_stats, insert_select_squashing_variant) are unrelated noise — this PR only touches HTTP connection establishment.

Comment thread src/Common/HostResolvePool.cpp
george-larionov and others added 5 commits June 23, 2026 06:47
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A connection that succeeds via selectBest's all-banned zero-weight branch
now clears the address's failed flag and decrements banned_count, instead
of leaving it stuck pessimized (failed with consecutive_fail_count==0, which
the backoff un-ban path skips). Adds a deterministic regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…h-all

Post-connect socket setup in Poco's HTTPSession::connect can throw
IOException/InvalidArgumentException (not NetException), reaching the
catch-all. Probe isConnectedToPeer as the NetException handler does: if the
TCP connect already succeeded, propagate without setFail so a local
socket-option error no longer bans a reachable address.

Addresses review thread: #103786 (comment)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`StillBannedAfterSuccess` asserted that an address stays banned after a concurrent success, but commit 2193709 ("HostResolver: un-ban address on success through zero-weight branch") deliberately reversed that: a successful connection now clears the `failed` flag and decrements `banned_count`, even when the address was handed out while failed. The pre-existing test was not updated and started failing deterministically on every sanitizer build (asan_ubsan/tsan/msan): expected `banned_count == 1`, got `0` at gtest_resolve_pool.cpp.

Rename it to `UnbannedAfterConcurrentSuccess` and assert the address is un-banned (`banned_count == 0`), matching the documented behavior and the new `SuccessThroughZeroWeightBranchUnbansAddress` test.

CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=103786&sha=a6c78f8cddef7f82f844e812c2927d8e5501b5d0&name_0=PR&name_1=Unit%20tests%20%28asan_ubsan%29

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/Common/HTTPConnectionPool.cpp Outdated
george-larionov and others added 4 commits June 24, 2026 06:06
…hake timeout

For HTTPS the TLS handshake runs after the TCP connect and can throw
Poco::TimeoutException once the socket has a peer. Probe isConnectedToPeer in
the timeout handler as the NetException handler does: if TCP already connected,
propagate without setFail; only pre-peer timeouts are per-address failures.

Addresses review thread: #103786 (comment)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ion' into http-connect-retry-on-net-exception
The three connect-failure handlers repeated the same try/setFail/catch/log
block. Extract it into a noexcept lambda; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mments

The NetException and TimeoutException handlers became byte-identical after the
TLS-timeout fix; factor their body into a shared onConnectFailure lambda. Also
trim verbose comments across the function and HostResolvePool.h. No behavior
change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@clickhouse-gh

clickhouse-gh Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 85.30% 85.40% +0.10%
Functions 92.50% 92.60% +0.10%
Branches 77.60% 77.50% -0.10%

Changed lines: Changed C/C++ lines covered by tests: 310/327 (94.80%) | Lost baseline coverage (was covered on master, now uncovered in this PR): 1 line(s) · Uncovered code

Full report · Diff report

@george-larionov
george-larionov added this pull request to the merge queue Jun 25, 2026
Merged via the queue into master with commit 50549cf Jun 25, 2026
165 of 167 checks passed
@george-larionov
george-larionov deleted the http-connect-retry-on-net-exception branch June 25, 2026 06:27
@robot-clickhouse-ci-1 robot-clickhouse-ci-1 added the pr-synced-to-cloud The PR is synced to the cloud repo label Jun 25, 2026
pull Bot pushed a commit to SINHASantos/ClickHouse that referenced this pull request Jun 26, 2026
The AI functions (`aiGenerate`, `aiClassify`, `aiExtract`, `aiTranslate`,
`aiEmbed`) retried only provider HTTP-error responses (the
`RECEIVED_ERROR_FROM_REMOTE_IO_SERVER` exception). A transient network
failure — connection refused/reset, a TLS connect failure, a connect/receive
timeout, or a host that advertises an unreachable address (e.g. an `AAAA`
record on a network without IPv6 routing) — was caught by a `catch (...)` that
never retried, so `ai_function_max_retries` did not cover the most common
transient failure mode. The `url` table function retries these via
`ReadWriteBufferFromHTTP::doWithRetries`.

Add `FunctionBaseAI::isRetriableProviderError`, which classifies the active
exception with the same policy as the url table function: transient network
failures (`DB::NetException`, `Poco::Net::NetException`,
`Poco::TimeoutException`) and provider-side HTTP errors are retriable, while
deterministic argument/usage errors are not. Route both AI execution paths (the
shared chat loop in `FunctionBaseAI::executeImpl` and the embedding loop in
`aiEmbed`) through it, collapsing the duplicated `catch` blocks.

The `ai_function_max_retries` default is left at `0`: unlike the idempotent GET
requests of the url function, AI requests are billed and not idempotent, so
retries stay opt-in. The change makes the existing setting actually cover
network failures once enabled.

Add integration coverage: a flaky mock endpoint that drops the connection for
the first N requests, exercising recovery for both the chat and embedding
paths, plus a check that disabling retries surfaces the network error.

Related: ClickHouse#103786

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-bugfix Pull request with bugfix, not backported by default pr-synced-to-cloud The PR is synced to the cloud repo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants