Try the next resolved address if HTTP connect fails - #103786
Conversation
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.
|
Workflow [PR], commit [04c29e8] Summary: ❌
AI ReviewSummaryThis 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 VerdictStatus: ✅ Approve |
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
…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
…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.
…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.
…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.
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>
…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>
`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>
…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>
`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>
|
Pushed Two changes:
The remaining performance-comparison failures ( |
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>
…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>
LLVM Coverage Report
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 |
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>
On macOS hosts where IPv6 is advertised by DNS but unreachable on the network,
aiGenerateand other outbound HTTP requests fail outright when the resolver picks the AAAA address:HostResolveralready records the failure viasetFailand 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::NetExceptionwith 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):
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
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-onlyHostResolversPool::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
26.7.1.50