From 1e2294232cedb30199aa85e1b057439a801c13e9 Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Tue, 23 Jun 2026 17:19:25 -0600 Subject: [PATCH] JSCO-74: Add per-request DNS record selection and RFC-aligned connection-error retry handling Changes -------- * Added per-request random A/AAAA record selection; requests now connect to the resolved IP while keeping the hostname for TLS SNI and the Host header. * Replaced the connection-error deny-list with an allowlist that retries only DNS and TCP-dial failures. * Fixed TLS handshake/certificate errors being retried; they now fail fast * Wrapped DNS-resolution failures (EAI_AGAIN, ENOTFOUND, empty result) as retriable connection errors. * Changed request log lines to show the resolved connect IP instead of the hostname. * Added unit tests for host selection, DNS-failure wrapping, and retry classification. --- lib/asyncqueryexecutor.ts | 32 +++--- lib/errorhandler.ts | 56 +++++------ lib/httpclient.ts | 84 +++++++++------- lib/queryexecutor.ts | 14 +-- test/errorhandler.test.ts | 89 +++++++++++++++++ test/hostselection.test.ts | 198 +++++++++++++++++++++++++++++++++++++ 6 files changed, 386 insertions(+), 87 deletions(-) create mode 100644 test/errorhandler.test.ts create mode 100644 test/hostselection.test.ts diff --git a/lib/asyncqueryexecutor.ts b/lib/asyncqueryexecutor.ts index 14bc63d..741195d 100644 --- a/lib/asyncqueryexecutor.ts +++ b/lib/asyncqueryexecutor.ts @@ -79,8 +79,8 @@ export class AsyncQueryExecutor extends QueryExecutor { const body = JSON.stringify(encodedOptions) return await runWithRetry( - () => { - const generic = this._cluster.httpClient.genericRequestOptions() + async () => { + const generic = await this._cluster.httpClient.requestOptions() const requestOptions: http.RequestOptions = { ...generic, method: 'POST', @@ -108,8 +108,8 @@ export class AsyncQueryExecutor extends QueryExecutor { this._requestContext.setGenericRequestContextFields('', statusHandle, 'GET') return await runWithRetry( - () => { - const generic = this._cluster.httpClient.genericRequestOptions() + async () => { + const generic = await this._cluster.httpClient.requestOptions() const requestOptions: http.RequestOptions = { ...generic, method: 'GET', @@ -136,8 +136,8 @@ export class AsyncQueryExecutor extends QueryExecutor { const body = `request_id=${encodeURIComponent(requestId)}` return await runWithRetry( - () => { - const generic = this._cluster.httpClient.genericRequestOptions() + async () => { + const generic = await this._cluster.httpClient.requestOptions() const requestOptions: http.RequestOptions = { ...generic, method: 'DELETE', @@ -168,8 +168,8 @@ export class AsyncQueryExecutor extends QueryExecutor { this._requestContext.setGenericRequestContextFields('', resultHandle, 'GET') return await runWithRetry( - () => { - const generic = this._cluster.httpClient.genericRequestOptions() + async () => { + const generic = await this._cluster.httpClient.requestOptions() const requestOptions: http.RequestOptions = { ...generic, method: 'GET', @@ -197,8 +197,8 @@ export class AsyncQueryExecutor extends QueryExecutor { ) return await runWithRetry( - () => { - const generic = this._cluster.httpClient.genericRequestOptions() + async () => { + const generic = await this._cluster.httpClient.requestOptions() const requestOptions: http.RequestOptions = { ...generic, method: 'DELETE', @@ -228,7 +228,7 @@ export class AsyncQueryExecutor extends QueryExecutor { requestOptions, (res) => { CouchbaseLogger.debug( - `Received startQuery response from ${requestOptions.hostname}:${requestOptions.port}. statusCode=${res.statusCode} clientContextId=${this._clientContextId}` + `Received startQuery response from ${requestOptions.host}:${requestOptions.port}. statusCode=${res.statusCode} clientContextId=${this._clientContextId}` ) this._handleJsonResponse(res, resolve, reject, ['requestID', 'handle']) } @@ -240,7 +240,7 @@ export class AsyncQueryExecutor extends QueryExecutor { req.on('error', (err) => { CouchbaseLogger.error( - `Error sending startQuery request to ${requestOptions.hostname}:${requestOptions.port}, details: ${err.message}. clientContextId=${this._clientContextId}` + `Error sending startQuery request to ${requestOptions.host}:${requestOptions.port}, details: ${err.message}. clientContextId=${this._clientContextId}` ) req.destroy() this._signal.removeEventListener('abort', abortHandler) @@ -249,7 +249,7 @@ export class AsyncQueryExecutor extends QueryExecutor { req.on('connectTimeout', () => { CouchbaseLogger.error( - `Connection timeout for startQuery request to ${requestOptions.hostname}:${requestOptions.port}. clientContextId=${this._clientContextId}` + `Connection timeout for startQuery request to ${requestOptions.host}:${requestOptions.port}. clientContextId=${this._clientContextId}` ) req.destroy() this._signal.removeEventListener('abort', abortHandler) @@ -259,7 +259,7 @@ export class AsyncQueryExecutor extends QueryExecutor { this._attachConnectTimeout(req) CouchbaseLogger.debug( - `Sending startQuery request to ${requestOptions.hostname}:${requestOptions.port}. body=${body}. clientContextId=${this._clientContextId}` + `Sending startQuery request to ${requestOptions.host}:${requestOptions.port}. body=${body}. clientContextId=${this._clientContextId}` ) req.write(body) req.end() @@ -292,7 +292,7 @@ export class AsyncQueryExecutor extends QueryExecutor { requestOptions, (res) => { CouchbaseLogger.debug( - `Received response from ${requestOptions.hostname}:${requestOptions.port}. statusCode=${res.statusCode} path=${requestOptions.path} clientContextId=${this._clientContextId}` + `Received response from ${requestOptions.host}:${requestOptions.port}. statusCode=${res.statusCode} path=${requestOptions.path} clientContextId=${this._clientContextId}` ) this._handleJsonResponse(res, resolve, reject) } @@ -304,7 +304,7 @@ export class AsyncQueryExecutor extends QueryExecutor { req.on('error', (err) => { CouchbaseLogger.error( - `Error sending request to ${requestOptions.hostname}:${requestOptions.port}${requestOptions.path}, details: ${err.message}. clientContextId=${this._clientContextId}` + `Error sending request to ${requestOptions.host}:${requestOptions.port}${requestOptions.path}, details: ${err.message}. clientContextId=${this._clientContextId}` ) req.destroy() this._signal.removeEventListener('abort', abortHandler) diff --git a/lib/errorhandler.ts b/lib/errorhandler.ts index 3e3661d..4c01703 100644 --- a/lib/errorhandler.ts +++ b/lib/errorhandler.ts @@ -211,43 +211,37 @@ export class ErrorHandler { return false } - return !connectionDenyList.has(nodeError.code) + // Per the RFC, only DNS and TCP-dial failures retry; anything after the + // socket is dialed (TLS handshake, cert verification) must fail fast. + return connectionRetryAllowList.has(nodeError.code) } } /** - * Taken from https://github.com/sindresorhus/is-retry-allowed + * Node `err.code`s for the DNS and TCP-dial failures the RFC marks retriable. + * * @internal */ -const connectionDenyList = new Set([ +const connectionRetryAllowList = new Set([ + // DNS resolution failures + 'EAI_AGAIN', 'ENOTFOUND', + // TCP-dial / connection failures + 'ECONNREFUSED', + 'ECONNRESET', + 'ECONNABORTED', + 'ETIMEDOUT', + 'EHOSTUNREACH', + 'EHOSTDOWN', 'ENETUNREACH', - 'UNABLE_TO_GET_ISSUER_CERT', - 'UNABLE_TO_GET_CRL', - 'UNABLE_TO_DECRYPT_CERT_SIGNATURE', - 'UNABLE_TO_DECRYPT_CRL_SIGNATURE', - 'UNABLE_TO_DECODE_ISSUER_PUBLIC_KEY', - 'CERT_SIGNATURE_FAILURE', - 'CRL_SIGNATURE_FAILURE', - 'CERT_NOT_YET_VALID', - 'CERT_HAS_EXPIRED', - 'CRL_NOT_YET_VALID', - 'CRL_HAS_EXPIRED', - 'ERROR_IN_CERT_NOT_BEFORE_FIELD', - 'ERROR_IN_CERT_NOT_AFTER_FIELD', - 'ERROR_IN_CRL_LAST_UPDATE_FIELD', - 'ERROR_IN_CRL_NEXT_UPDATE_FIELD', - 'OUT_OF_MEM', - 'DEPTH_ZERO_SELF_SIGNED_CERT', - 'SELF_SIGNED_CERT_IN_CHAIN', - 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', - 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', - 'CERT_CHAIN_TOO_LONG', - 'CERT_REVOKED', - 'INVALID_CA', - 'PATH_LENGTH_EXCEEDED', - 'INVALID_PURPOSE', - 'CERT_UNTRUSTED', - 'CERT_REJECTED', - 'HOSTNAME_MISMATCH', + 'ENETDOWN', + 'ENETRESET', + 'EADDRNOTAVAIL', + // EPIPE looks like a post-dial write failure, but it only reaches here as a + // request-side error (isRequestError), meaning the peer tore down the + // connection before accepting our request (e.g. a closed keep-alive socket, + // an LB drop, or a restarting node). Nothing was processed, so retrying is + // safe. A broken pipe after the server began responding surfaces as a + // response-side error and fails fast instead. + 'EPIPE', ]) diff --git a/lib/httpclient.ts b/lib/httpclient.ts index c0177cf..6d7ca3f 100644 --- a/lib/httpclient.ts +++ b/lib/httpclient.ts @@ -19,6 +19,7 @@ import { Agent as HttpAgent } from 'node:http' import { Agent as HttpsAgent } from 'node:https' import { isIP } from 'node:net' import { AnalyticsError, InvalidArgumentError } from './errors.js' +import { ConnectionError } from './internalerrors.js' import type { ClusterCredential } from './credential.js' import { SecurityOptions } from './cluster.js' import { Certificates } from './certificates.js' @@ -47,7 +48,6 @@ export class HttpClient { this._hostname = url.hostname this._credential = credential this._securityOptions = securityOptions - this.randomLookup = this.randomLookup.bind(this) if (url.protocol === 'http:') { if (credential.type === 'certificate') { @@ -77,15 +77,15 @@ export class HttpClient { } /** - * Returns request options with the current credential's `Authorization` - * header set. + * Returns the credential/agent/port portion of the request options, with the + * current credential's `Authorization` header set. Does NOT resolve the host; + * see {@link requestOptions} for the per-request target selection. * * @internal */ genericRequestOptions(): http.RequestOptions { const opts: http.RequestOptions = { agent: this._agent, - hostname: this._hostname, port: this._port, } if (this._credential.type !== 'certificate') { @@ -94,6 +94,32 @@ export class HttpClient { return opts } + /** + * Builds the options for a single request. Per the RFC, resolves the + * hostname and picks a random A/AAAA record per request (so the keep-alive + * agent does not pin to one node), connecting to that IP. The hostname is + * kept for the TLS SNI `servername` (via the agent) and the `Host` header, + * so cert verification and vhost routing are unaffected. An IP literal is + * used as-is. + * + * @internal + */ + async requestOptions(): Promise { + const opts = this.genericRequestOptions() + + if (isIP(this._hostname) !== 0) { + opts.host = this._hostname + return opts + } + + opts.host = await this._selectRequestAddress() + opts.headers = { + ...opts.headers, + Host: `${this._hostname}:${this._port}`, + } + return opts + } + /** * Replace the credential used for subsequent requests. Cross-type * rotation throws `InvalidArgumentError`. @@ -129,7 +155,6 @@ export class HttpClient { if (this._module === http) { return new HttpAgent({ keepAlive: true, - lookup: this.randomLookup, }) } const tlsOptions = this._buildTlsOptions() @@ -143,7 +168,6 @@ export class HttpClient { } return new HttpsAgent({ keepAlive: true, - lookup: this.randomLookup, ...tlsOptions, }) } @@ -194,35 +218,29 @@ export class HttpClient { } /** + * Resolves the hostname's A/AAAA records via `dns.lookup` (getaddrinfo) and + * returns one at random. DNS-resolution failures are wrapped as a request-side + * {@link ConnectionError} so they rejoin the retry path; we resolve before + * `http.request`, so they would otherwise never reach its `error` event. An + * empty result is treated like `ENOTFOUND`. + * * @internal */ - randomLookup( - hostname: string, - options: dns.LookupOptions, - callback: ( - err: NodeJS.ErrnoException | null, - address: string | dns.LookupAddress[], - family?: number - ) => void - ): void { - // There are two flavours of the callback signature. if 'all' is true: (err, address[]) else (err, address, family) - // On Node.js versions > 18, 'all' is true by default, which means we have to handle both cases. - // See https://github.com/nodejs/node/issues/55762 - const wantAll = options.all - dns.lookup(hostname, { ...options, all: true }, (err, addresses) => { - if (err || addresses.length === 0) { - const e = err ?? new Error(`No addresses found for ${hostname}`) - return callback(e, wantAll ? [] : '', undefined) - } - const selectedAddress = - addresses[Math.floor(Math.random() * addresses.length)] - - if (wantAll) { - callback(null, [selectedAddress]) - } else { - callback(null, selectedAddress.address, selectedAddress.family) - } - }) + private async _selectRequestAddress(): Promise { + let addresses: dns.LookupAddress[] + try { + addresses = await dns.promises.lookup(this._hostname, { all: true }) + } catch (err) { + throw new ConnectionError(err as Error, true) + } + if (addresses.length === 0) { + const noRecords = new Error( + `No addresses found for ${this._hostname}` + ) as NodeJS.ErrnoException + noRecords.code = 'ENOTFOUND' + throw new ConnectionError(noRecords, true) + } + return addresses[Math.floor(Math.random() * addresses.length)].address } /** diff --git a/lib/queryexecutor.ts b/lib/queryexecutor.ts index 14c40f2..3161578 100644 --- a/lib/queryexecutor.ts +++ b/lib/queryexecutor.ts @@ -123,10 +123,10 @@ export class QueryExecutor { const body = JSON.stringify(encodedOptions) return await runWithRetry( - () => { + async () => { // Rebuild per attempt so a credential rotated mid-query takes effect - // on the next retry. - const generic = this._cluster.httpClient.genericRequestOptions() + // on the next retry, and so each attempt re-selects an A/AAAA record. + const generic = await this._cluster.httpClient.requestOptions() const requestOptions: http.RequestOptions = { ...generic, method: 'POST', @@ -167,7 +167,7 @@ export class QueryExecutor { requestOptions, (res) => { CouchbaseLogger.debug( - `Received query response from ${requestOptions.hostname}:${requestOptions.port}. statusCode=${res.statusCode} clientContextId=${this._clientContextId}` + `Received query response from ${requestOptions.host}:${requestOptions.port}. statusCode=${res.statusCode} clientContextId=${this._clientContextId}` ) this._handleStreamingResponse(res, resolve, reject, deadline) } @@ -179,7 +179,7 @@ export class QueryExecutor { req.on('error', (err) => { CouchbaseLogger.error( - `Error occurred while sending query request to ${requestOptions.hostname}:${requestOptions.port}, details: ${err.message}. clientContextId=${this._clientContextId}` + `Error occurred while sending query request to ${requestOptions.host}:${requestOptions.port}, details: ${err.message}. clientContextId=${this._clientContextId}` ) req.destroy() this._signal.removeEventListener('abort', abortHandler) @@ -188,7 +188,7 @@ export class QueryExecutor { req.on('connectTimeout', () => { CouchbaseLogger.error( - `Connection timeout for query request to ${requestOptions.hostname}:${requestOptions.port}. clientContextId=${this._clientContextId}` + `Connection timeout for query request to ${requestOptions.host}:${requestOptions.port}. clientContextId=${this._clientContextId}` ) req.destroy() this._signal.removeEventListener('abort', abortHandler) @@ -198,7 +198,7 @@ export class QueryExecutor { this._attachConnectTimeout(req) CouchbaseLogger.debug( - `Sending request to ${requestOptions.hostname}:${requestOptions.port}. body=${body}. clientContextId=${this._clientContextId}` + `Sending request to ${requestOptions.host}:${requestOptions.port}. body=${body}. clientContextId=${this._clientContextId}` ) req.write(body) diff --git a/test/errorhandler.test.ts b/test/errorhandler.test.ts new file mode 100644 index 0000000..9870a9e --- /dev/null +++ b/test/errorhandler.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2016-2026. Couchbase, Inc. + * 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. + */ + +import { assert } from 'chai' +import { ErrorHandler } from '../lib/errorhandler.js' +import { ConnectionError } from '../lib/internalerrors.js' +import { RequestContext } from '../lib/requestcontext.js' + +// Build a ConnectionError wrapping a Node system error carrying `code`, +// mirroring what the executor produces from a request `error` event. +function connectionError(code: string | undefined, isRequestError = true): ConnectionError { + const cause = new Error(`synthetic ${code ?? 'no-code'} error`) as NodeJS.ErrnoException + if (code !== undefined) { + cause.code = code + } + return new ConnectionError(cause, isRequestError) +} + +function classify(err: ConnectionError): boolean { + // handleErrors routes through the private _isRetriableConnectionError. + return ErrorHandler.handleErrors(err, new RequestContext(7)).shouldRetry() +} + +// Per the RFC, only DNS failures and TCP-dial connection failures are eligible +// for retry (allowlist). Anything that happens after the socket is dialed -- the +// TLS handshake, cert/hostname verification, response read/write -- must fail +// fast. +describe('#ConnectionError retry classification', function () { + it('does not retry a TLS hostname/altname mismatch', function () { + assert.isFalse(classify(connectionError('ERR_TLS_CERT_ALTNAME_INVALID'))) + }) + + it('does not retry other ERR_TLS_* handshake errors', function () { + assert.isFalse(classify(connectionError('ERR_TLS_HANDSHAKE_TIMEOUT'))) + }) + + it('does not retry ERR_SSL_* errors', function () { + assert.isFalse(classify(connectionError('ERR_SSL_WRONG_VERSION_NUMBER'))) + }) + + it('does not retry an OpenSSL-style cert verification code', function () { + // e.g. an expired/self-signed cert -- a handshake failure, not a dial failure. + assert.isFalse(classify(connectionError('CERT_HAS_EXPIRED'))) + assert.isFalse(classify(connectionError('DEPTH_ZERO_SELF_SIGNED_CERT'))) + }) + + it('retries TCP-dial connection failures', function () { + assert.isTrue(classify(connectionError('ECONNREFUSED'))) + assert.isTrue(classify(connectionError('ECONNRESET'))) + assert.isTrue(classify(connectionError('ETIMEDOUT'))) + // Network-unreachable is a connection failure -- the old denylist wrongly + // blocked it; the allowlist restores RFC-correct retry behavior. + assert.isTrue(classify(connectionError('ENETUNREACH'))) + }) + + it('retries DNS-resolution failures', function () { + assert.isTrue(classify(connectionError('EAI_AGAIN'))) + assert.isTrue(classify(connectionError('ENOTFOUND'))) + }) + + it('does not retry an unrecognized read/write error code', function () { + // Not a DNS or TCP-dial failure -> not on the allowlist -> fail fast. + assert.isFalse(classify(connectionError('EPROTO'))) + assert.isFalse(classify(connectionError('ERR_STREAM_PREMATURE_CLOSE'))) + }) + + it('does not retry a response (non-request) error', function () { + // Errors raised while reading the response are read/write-after-dial. + assert.isFalse(classify(connectionError('ECONNREFUSED', false))) + }) + + it('does not retry a connection error with no code', function () { + assert.isFalse(classify(connectionError(undefined))) + }) +}) diff --git a/test/hostselection.test.ts b/test/hostselection.test.ts new file mode 100644 index 0000000..c073611 --- /dev/null +++ b/test/hostselection.test.ts @@ -0,0 +1,198 @@ +/* + * Copyright 2016-2026. Couchbase, Inc. + * 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. + */ + +import { assert } from 'chai' +import * as dns from 'node:dns' +import * as http from 'node:http' +import { AddressInfo } from 'node:net' +import { Credential, createInstance } from '../lib/analytics.js' +import { ConnectionError } from '../lib/internalerrors.js' + +// Per the RFC, each request MUST use a random selection from the hostname's +// A/AAAA records rather than reusing the same record over and over, while the +// original hostname is preserved for TLS verification and the Host header. +describe('#Host selection', function () { + const HOSTNAME = 'my.cluster.example.com' + const PORT = 18095 + const realLookup = dns.promises.lookup + + // Stub dns.promises.lookup (shared builtin) so address selection is + // deterministic and offline. HttpClient resolves via this per request. + function stubLookup(addresses: dns.LookupAddress[] | (() => never)): void { + ;(dns.promises as { lookup: unknown }).lookup = async () => { + if (typeof addresses === 'function') return addresses() + return addresses + } + } + + afterEach(function () { + ;(dns.promises as { lookup: unknown }).lookup = realLookup + }) + + it('selects a random A/AAAA record per request (no pinning)', async function () { + stubLookup([ + { address: '10.0.0.1', family: 4 }, + { address: '10.0.0.2', family: 4 }, + ]) + const cluster = createInstance( + `https://${HOSTNAME}:${PORT}`, + new Credential('u', 'p'), + { securityOptions: { disableServerCertificateVerification: true } } + ) + try { + const seen = new Set() + for (let i = 0; i < 50; i++) { + const opts = await cluster.httpClient.requestOptions() + seen.add(opts.host as string) + } + // Over 50 requests gives solid odds that both records have been used + assert.sameMembers([...seen], ['10.0.0.1', '10.0.0.2']) + } finally { + cluster.close() + } + }) + + it('connects to the resolved IP but keeps the hostname for the Host header', async function () { + stubLookup([{ address: '10.1.2.3', family: 4 }]) + const cluster = createInstance( + `https://${HOSTNAME}:${PORT}`, + new Credential('Administrator', 'password'), + { securityOptions: { disableServerCertificateVerification: true } } + ) + try { + const opts = await cluster.httpClient.requestOptions() + // TCP target is the resolved IP... + assert.strictEqual(opts.host, '10.1.2.3') + // ...but the Host header (vhost routing) stays the original hostname. + const headers = opts.headers as Record + assert.strictEqual(headers.Host, `${HOSTNAME}:${PORT}`) + // ...and the Authorization header is preserved alongside it. + assert.strictEqual( + headers.Authorization, + 'Basic ' + Buffer.from('Administrator:password').toString('base64') + ) + } finally { + cluster.close() + } + }) + + it('uses an IP-literal endpoint as-is without resolving DNS', async function () { + // If DNS were consulted for an IP literal this stub would throw. + stubLookup(() => { + throw new Error('dns.lookup must not be called for an IP literal') + }) + const cluster = createInstance( + `https://10.9.8.7:${PORT}`, + new Credential('u', 'p'), + { securityOptions: { disableServerCertificateVerification: true } } + ) + try { + const opts = await cluster.httpClient.requestOptions() + assert.strictEqual(opts.host, '10.9.8.7') + // No Host-header override for an IP literal (RFC 6066: no SNI/vhost name). + const headers = (opts.headers ?? {}) as Record + assert.isUndefined(headers.Host) + } finally { + cluster.close() + } + }) + + it('rejects with a retriable ConnectionError when no records resolve', async function () { + // An empty result is a DNS failure; per the RFC it should rejoin the retry + // path, so it is wrapped as a request-side ConnectionError carrying ENOTFOUND. + stubLookup([]) + const cluster = createInstance( + `https://${HOSTNAME}:${PORT}`, + new Credential('u', 'p'), + { securityOptions: { disableServerCertificateVerification: true } } + ) + try { + let caught: unknown + try { + await cluster.httpClient.requestOptions() + } catch (e) { + caught = e + } + assert.instanceOf(caught, ConnectionError) + const err = caught as ConnectionError + assert.isTrue(err.isRequestError) + assert.strictEqual((err.cause as NodeJS.ErrnoException).code, 'ENOTFOUND') + } finally { + cluster.close() + } + }) + + it('wraps a DNS-resolution failure as a retriable ConnectionError', async function () { + // e.g. a transient resolver failure during a rebalance. + stubLookup(() => { + const e = new Error('getaddrinfo EAI_AGAIN') as NodeJS.ErrnoException + e.code = 'EAI_AGAIN' + throw e + }) + const cluster = createInstance( + `https://${HOSTNAME}:${PORT}`, + new Credential('u', 'p'), + { securityOptions: { disableServerCertificateVerification: true } } + ) + try { + let caught: unknown + try { + await cluster.httpClient.requestOptions() + } catch (e) { + caught = e + } + assert.instanceOf(caught, ConnectionError) + const err = caught as ConnectionError + // isRequestError + a DNS code is what ErrorHandler classifies as retriable. + assert.isTrue(err.isRequestError) + assert.strictEqual((err.cause as NodeJS.ErrnoException).code, 'EAI_AGAIN') + } finally { + cluster.close() + } + }) + + it('sends the Host header as the hostname (not the IP) on the actual request', async function () { + // End-to-end: drive a real request through the executor and capture what + // arrives at the server. DNS is stubbed to 127.0.0.1 where the server runs, + // so the client connects to the IP but must send Host: :. + this.timeout(10000) + let capturedHost: string | undefined + const server = http.createServer((req) => { + capturedHost = req.headers.host + req.socket.destroy() + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + + stubLookup([{ address: '127.0.0.1', family: 4 }]) + const cluster = createInstance( + `http://${HOSTNAME}:${port}`, + new Credential('u', 'p') + ) + try { + await cluster + .executeQuery('SELECT 1', { maxRetries: 0, timeout: 2000 }) + .catch(() => undefined) + assert.strictEqual(capturedHost, `${HOSTNAME}:${port}`) + } finally { + cluster.close() + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ) + } + }) +})