diff --git a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt index 4b0a727f6de0..c97de41d4eae 100644 --- a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -22,9 +22,7 @@ import app.cash.burst.Burst import assertk.assertThat import assertk.assertions.contains import assertk.assertions.doesNotContain -import assertk.assertions.isEqualTo import assertk.assertions.isFalse -import assertk.assertions.isTrue import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request @@ -77,21 +75,33 @@ class EchTest( } @Test - fun staleEchConfigIsNotRetried() { - val rejection = client.echRejectionFrom("https://stale.tls-ech.dev/") + fun staleEchConfigIsRetried() { + val body = client.get("https://stale.tls-ech.dev/") - // TODO retry with these, then assert "You are using ECH" like tlsEchDevUsesEch. - assertThat(rejection.hasRetryConfigList()).isTrue() - assertThat(rejection.publicHostname).isEqualTo("public.tls-ech.dev") + assertThat(body).contains("You are using ECH") + assertThat(body).doesNotContain("not using ECH") } @Test - fun wrongPublicNameIsNotRetried() { - val rejection = client.echRejectionFrom("https://wrong.tls-ech.dev/") + fun differentPublicHostnameIsVerifiedBeforeRetry() { + // The outer certificate authenticates public.tls-ech.dev, + // so the retry config may be used if it matches. + // https://www.rfc-editor.org/rfc/rfc9849.html#section-6.1.6 + val verifiedHostnames = mutableListOf() + val hostnameVerifier = client.hostnameVerifier + val client = + client + .newBuilder() + .hostnameVerifier { hostname, session -> + verifiedHostnames += hostname + hostnameVerifier.verify(hostname, session) + } + .build() - // TODO retry with these, then assert "You are using ECH" like tlsEchDevUsesEch. - assertThat(rejection.hasRetryConfigList()).isTrue() - assertThat(rejection.publicHostname).isEqualTo("public.tls-ech.dev") + val body = client.get("https://wrong.tls-ech.dev/") + + assertThat(body).contains("You are using ECH") + assertThat(verifiedHostnames).contains("public.tls-ech.dev") } /** @@ -104,8 +114,6 @@ class EchTest( /** * Makes the call at [url] and returns the ECH rejection it fails with. - * - * TODO handle EchConfigMismatchException.retry_configs. */ private fun OkHttpClient.echRejectionFrom(url: String): EchConfigMismatchException { val body = diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt index 78fff4e85481..0f7075ac9935 100644 --- a/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt @@ -275,7 +275,7 @@ class FakeDns( } is ResourceRecord.IpAddress -> { - val ipAddressRecord = Dns.Record.IpAddress(request.hostname, resourceRecord.address) + val ipAddressRecord = Dns.Record.IpAddress(resourceRecord.name, resourceRecord.address) when (resourceRecord.address) { is Inet4Address -> ipv4Records += ipAddressRecord is Inet6Address -> ipv6Records += ipAddressRecord diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt index e89e0260f59c..0d212706918b 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt @@ -17,17 +17,20 @@ package okhttp3.internal.platform import android.annotation.SuppressLint import android.content.Context +import android.net.ssl.EchConfigMismatchException import android.os.Build import android.os.StrictMode import android.security.NetworkSecurityPolicy import android.util.CloseGuard import android.util.Log import javax.net.ssl.SSLContext +import javax.net.ssl.SSLException import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck +import okhttp3.internal.dns.EchRetryConfig import okhttp3.internal.platform.AndroidPlatform.Companion.Tag import okhttp3.internal.platform.android.Android10SocketAdapter import okhttp3.internal.platform.android.Android17SocketAdapter @@ -39,6 +42,7 @@ import okhttp3.internal.platform.android.DeferredSocketAdapter import okhttp3.internal.tls.CertificateChainCleaner import okhttp3.internal.tls.TrustRootIndex import okio.ByteString +import okio.ByteString.Companion.toByteString /** Android 10+ (API 29+). */ @SuppressSignatureCheck @@ -86,6 +90,20 @@ class Android10Platform : ?.configureTlsExtensions(sslSocket, hostname, protocols, echConfigList) } + @SuppressLint("NewApi") + internal override fun getEchRetryConfig(exception: SSLException): EchRetryConfig? { + if (Build.VERSION.SDK_INT < 37 || exception !is EchConfigMismatchException) return null + + return EchRetryConfig( + publicHostname = exception.publicHostname ?: return null, + configList = + exception.retryConfigList + ?.toBytes() + ?.toByteString() + ?: return null, + ) + } + override fun getSelectedProtocol(sslSocket: SSLSocket): String? = // No TLS extensions if the socket class is custom. socketAdapters.find { it.matchesSocket(sslSocket) }?.getSelectedProtocol(sslSocket) diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt index 5047053680d5..a408875bf43a 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt @@ -25,6 +25,7 @@ import java.net.Socket as JavaNetSocket import java.net.UnknownServiceException import java.security.cert.X509Certificate import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLException import javax.net.ssl.SSLPeerUnverifiedException import javax.net.ssl.SSLSocket import okhttp3.CertificatePinner @@ -38,6 +39,7 @@ import okhttp3.internal.closeQuietly import okhttp3.internal.concurrent.TaskRunner import okhttp3.internal.concurrent.withLock import okhttp3.internal.connection.RoutePlanner.ConnectResult +import okhttp3.internal.dns.EchRetryConfig import okhttp3.internal.http.ExchangeCodec import okhttp3.internal.http1.Http1ExchangeCodec import okhttp3.internal.platform.Platform @@ -73,6 +75,7 @@ class ConnectPlan internal constructor( private val tunnelRequest: Request?, internal val connectionSpecIndex: Int, internal val isTlsFallback: Boolean, + private val echRetryConfig: EchRetryConfig? = null, ) : RoutePlanner.Plan, ExchangeCodec.Carrier { /** True if this connect was canceled; typically because it lost a race. */ @@ -98,10 +101,12 @@ class ConnectPlan internal constructor( get() = protocol != null private fun copy( + route: Route = this.route, attempt: Int = this.attempt, tunnelRequest: Request? = this.tunnelRequest, connectionSpecIndex: Int = this.connectionSpecIndex, isTlsFallback: Boolean = this.isTlsFallback, + echRetryConfig: EchRetryConfig? = this.echRetryConfig, ): ConnectPlan = ConnectPlan( taskRunner = taskRunner, @@ -120,6 +125,7 @@ class ConnectPlan internal constructor( tunnelRequest = tunnelRequest, connectionSpecIndex = connectionSpecIndex, isTlsFallback = isTlsFallback, + echRetryConfig = echRetryConfig, ) override fun connectTcp(): ConnectResult { @@ -200,11 +206,13 @@ class ConnectPlan internal constructor( val tlsEquipPlan = planWithCurrentOrInitialConnectionSpec(connectionSpecs, sslSocket) val connectionSpec = connectionSpecs[tlsEquipPlan.connectionSpecIndex] - // Figure out the next connection spec in case we need a retry. - retryTlsConnection = tlsEquipPlan.nextConnectionSpec(connectionSpecs, sslSocket) - connectionSpec.apply(sslSocket, isFallback = tlsEquipPlan.isTlsFallback) - connectTls(sslSocket, connectionSpec) + try { + connectTls(sslSocket, connectionSpec) + } catch (e: SSLException) { + retryTlsConnection = tlsEquipPlan.nextConnectionSpec(connectionSpecs, sslSocket, e) + throw e + } call.eventListener.secureConnectEnd(call, handshake) } else { javaNetSocket = rawSocket @@ -239,10 +247,6 @@ class ConnectPlan internal constructor( call.eventListener.connectFailed(call, route.socketAddress, route.proxy, null, e) connectionPool.connectionListener.connectFailed(route, call, e) - if (!retryOnConnectionFailure || !retryTlsHandshake(e)) { - retryTlsConnection = null - } - return ConnectResult( plan = this, nextPlan = retryTlsConnection, @@ -479,7 +483,7 @@ class ConnectPlan internal constructor( sslSocket: SSLSocket, ): ConnectPlan { if (connectionSpecIndex != -1) return this - return nextConnectionSpec(connectionSpecs, sslSocket) + return nextCompatibleConnectionSpec(connectionSpecs, sslSocket) ?: throw UnknownServiceException( "Unable to find acceptable protocols." + " isFallback=$isTlsFallback," + @@ -489,12 +493,60 @@ class ConnectPlan internal constructor( } /** - * Returns a copy of this connection with the next connection spec to try, or null if no other - * compatible connection specs are available. + * Returns a copy of this connection that recovers from [sslException], or null if the failure + * should not be retried. */ internal fun nextConnectionSpec( connectionSpecs: List, sslSocket: SSLSocket, + sslException: SSLException, + ): ConnectPlan? { + if (!retryOnConnectionFailure) return null + + val offeredEchRetryConfig = Platform.get().getEchRetryConfig(sslException) + if (offeredEchRetryConfig != null) { + // TODO should we emit an event that we considered ech retry? + + // Only use ECH retry once + if (echRetryConfig != null) return null + + // Validate the publicHostname against the session certificate + if ( + !route.address.hostnameVerifier!!.verify( + offeredEchRetryConfig.publicHostname, + sslSocket.session, + ) + ) { + return null + } + + // retry with an updated ECH config + return copy( + route = + Route( + address = route.address, + proxy = route.proxy, + socketAddress = route.socketAddress, + echConfigList = offeredEchRetryConfig.configList, + ), + echRetryConfig = offeredEchRetryConfig, + ) + } + + // If this was already in response to an ech retry, we are done for this + // connection + if (echRetryConfig != null || !retryTlsHandshake(sslException)) return null + + return nextCompatibleConnectionSpec(connectionSpecs, sslSocket) + } + + /** + * Returns a copy of this connection with the next compatible connection spec, or null if none + * are available. + */ + private fun nextCompatibleConnectionSpec( + connectionSpecs: List, + sslSocket: SSLSocket, ): ConnectPlan? { for (i in connectionSpecIndex + 1 until connectionSpecs.size) { if (connectionSpecs[i].isCompatible(sslSocket)) { @@ -561,6 +613,7 @@ class ConnectPlan internal constructor( tunnelRequest = tunnelRequest, connectionSpecIndex = connectionSpecIndex, isTlsFallback = isTlsFallback, + echRetryConfig = echRetryConfig, ) fun closeQuietly() { diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt index 0bba12759fb4..e144932f78d6 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt @@ -181,10 +181,14 @@ class RouteSelector internal constructor( val routes = dnsLookup(proxy, socketHost, socketPort) + // If DNS advertises ECH for any route, don't permit a retry without ECH. + val echRoutes = routes.filter { it.echConfigList != null } + val routesToTry = echRoutes.ifEmpty { routes } + // Try each address for best behavior in mixed IPv4/IPv6 environments. return when { - fastFallback -> reorderForHappyEyeballs(routes) - else -> routes + fastFallback -> reorderForHappyEyeballs(routesToTry) + else -> routesToTry } } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt new file mode 100644 index 000000000000..d7fb7ae4e70b --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * 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 okhttp3.internal.dns + +import okio.ByteString + +/** + * ECH Retry config. Generally sent by a server when there is a + * mismatch between A/AAAA and HTTPS Records. Must only be used + * when publicHostname can be validated against the certificate + * from the SSLSession. + */ +internal data class EchRetryConfig( + val configList: ByteString, + val publicHostname: String, +) diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt index 3633ab55801d..8f9eb1a45406 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt @@ -26,6 +26,7 @@ import java.util.logging.Logger import javax.net.ssl.ExtendedSSLSession import javax.net.ssl.SNIHostName import javax.net.ssl.SSLContext +import javax.net.ssl.SSLException import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager @@ -34,6 +35,7 @@ import javax.net.ssl.X509TrustManager import okhttp3.Dns import okhttp3.OkHttpClient import okhttp3.Protocol +import okhttp3.internal.dns.EchRetryConfig import okhttp3.internal.publicsuffix.PublicSuffixDatabase import okhttp3.internal.readFieldOrNull import okhttp3.internal.tls.BasicCertificateChainCleaner @@ -122,6 +124,9 @@ open class Platform { ) { } + /** Returns the ECH retry configuration carried by [exception]. */ + internal open fun getEchRetryConfig(exception: SSLException): EchRetryConfig? = null + /** Called after the TLS handshake to release resources allocated by [configureTlsExtensions]. */ open fun afterHandshake(sslSocket: SSLSocket) { } diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/InterceptorOverridesTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/InterceptorOverridesTest.kt index b664b7f3547c..9b4b4399bb4c 100644 --- a/okhttp/src/jvmTest/kotlin/okhttp3/InterceptorOverridesTest.kt +++ b/okhttp/src/jvmTest/kotlin/okhttp3/InterceptorOverridesTest.kt @@ -39,7 +39,6 @@ import java.util.Locale.getDefault import java.util.concurrent.TimeUnit import javax.net.SocketFactory import javax.net.ssl.HostnameVerifier -import javax.net.ssl.SSLException import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager @@ -242,23 +241,15 @@ class InterceptorOverridesTest { OverrideParam.RetryOnConnectionFailure -> { enableTls() - var first = true + + server.enqueue(MockResponse.Builder().failHandshake().build()) client = client .newBuilder() .connectionSpecs(listOf(ConnectionSpec.RESTRICTED_TLS, ConnectionSpec.MODERN_TLS)) - .eventListener( - object : EventListener() { - override fun secureConnectEnd( - call: Call, - handshake: Handshake?, - ) { - if (first) { - first = false - throw SSLException("") - } - } - }, + .sslSocketFactory( + FallbackTestClientSocketFactory(handshakeCertificates.sslSocketFactory()), + handshakeCertificates.trustManager, ).build() overrideBadImplementation( diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt index 8d75d19e9d6d..aed129234f8a 100644 --- a/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt +++ b/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt @@ -17,20 +17,28 @@ package okhttp3.internal.connection import assertk.assertThat import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.isEqualTo import assertk.assertions.isFalse +import assertk.assertions.isNotEqualTo import assertk.assertions.isNotNull import assertk.assertions.isNull import assertk.assertions.isTrue import java.io.IOException import java.security.cert.CertificateException +import javax.net.ssl.SSLException import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLSocket import okhttp3.ConnectionSpec +import okhttp3.FakeDns import okhttp3.OkHttpClientTestRule import okhttp3.TestValueFactory import okhttp3.TlsVersion +import okhttp3.internal.dns.EchRetryConfig +import okhttp3.internal.dns.ResourceRecord +import okhttp3.internal.platform.Platform import okhttp3.testing.PlatformRule import okhttp3.tls.internal.TlsUtil.localhost +import okio.ByteString.Companion.encodeUtf8 import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -39,12 +47,25 @@ class RetryConnectionTest { private val factory = TestValueFactory() private val handshakeCertificates = localhost() private val retryableException = SSLHandshakeException("Simulated handshake exception") + private val echRetryException = SSLHandshakeException("Simulated ECH rejection") + private val echRetryConfig = + EchRetryConfig( + configList = "retry config".encodeUtf8(), + publicHostname = "public.tls-ech.dev", + ) @RegisterExtension val clientTestRule = OkHttpClientTestRule() @RegisterExtension - val platform = PlatformRule() + val platform = + PlatformRule( + platform = + object : Platform() { + override fun getEchRetryConfig(exception: SSLException): EchRetryConfig? = + if (exception === echRetryException) echRetryConfig else null + }, + ) private var client = clientTestRule.newClient() @@ -69,6 +90,98 @@ class RetryConnectionTest { assertThat(retryTlsHandshake(retryableException)).isTrue() } + @Test fun echRetryConfigIsUsedOnceWithoutTlsFallback() { + val verifiedHostnames = mutableListOf() + val address = + factory.newHttpsAddress( + hostnameVerifier = { hostname, _ -> + verifiedHostnames += hostname + true + }, + ) + val routePlanner = factory.newRoutePlanner(client, address) + val route = factory.newRoute(address) + val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS) + val socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1) + val attempt0 = + routePlanner + .planConnectToRoute(route) + .planWithCurrentOrInitialConnectionSpec(connectionSpecs, socket) + + val attempt1 = attempt0.nextConnectionSpec(connectionSpecs, socket, echRetryException) + + assertThat(attempt1).isNotNull() + assertThat(attempt1!!.route.echConfigList).isEqualTo(echRetryConfig.configList) + assertThat(attempt1.isTlsFallback).isFalse() + assertThat(verifiedHostnames).isEqualTo(listOf(echRetryConfig.publicHostname)) + + val attempt2 = attempt1.nextConnectionSpec(connectionSpecs, socket, retryableException) + assertThat(attempt2).isNull() + socket.close() + } + + /** https://www.rfc-editor.org/rfc/rfc9849.html#section-6.1.6 */ + @Test fun echRetryUsesOnlyAddressesFromOriginalDnsResults() { + val dns = FakeDns() + val hostname = "stale.tls-ech.dev" + val originalAddresses = dns.allocate(2) + val newAddress = dns.allocate(1).single() + factory.dns = dns + factory.uriHost = hostname + dns[hostname] = + listOf( + ResourceRecord.Https( + name = hostname, + timeToLive = 5, + echConfigList = "stale config".encodeUtf8(), + ), + *originalAddresses + .map { + ResourceRecord.IpAddress( + name = hostname, + timeToLive = 5, + address = it, + ) + }.toTypedArray(), + ) + val address = factory.newHttpsAddress(hostnameVerifier = { _, _ -> true }) + val routePlanner = factory.newRoutePlanner(client, address) + val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS) + val socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_2) + val attempt0 = + routePlanner + .planConnect() + .planWithCurrentOrInitialConnectionSpec(connectionSpecs, socket) + + // A new DNS result must not influence a retry of the previous ECH configuration. + dns[hostname] = listOf(newAddress) + val attempt1 = attempt0.nextConnectionSpec(connectionSpecs, socket, echRetryException) + + assertThat(attempt1).isNotNull() + assertThat(attempt1!!.route.socketAddress.address).isEqualTo(originalAddresses[0]) + assertThat(attempt1.route.socketAddress.address).isNotEqualTo(newAddress) + dns.assertRequests(hostname) + socket.close() + } + + @Test fun untrustedEchRetryConfigIsNotRetried() { + val address = factory.newHttpsAddress(hostnameVerifier = { _, _ -> false }) + val routePlanner = factory.newRoutePlanner(client, address) + val route = factory.newRoute(address) + val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS) + val socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1) + val attempt0 = + routePlanner + .planConnectToRoute(route) + .planWithCurrentOrInitialConnectionSpec(connectionSpecs, socket) + + // not retried because validation failed + val attempt1 = attempt0.nextConnectionSpec(connectionSpecs, socket, echRetryException) + + assertThat(attempt1).isNull() + socket.close() + } + @Test fun someFallbacksSupported() { val sslV3 = ConnectionSpec @@ -94,7 +207,7 @@ class RetryConnectionTest { assertThat(attempt0.isTlsFallback).isFalse() connectionSpecs[attempt0.connectionSpecIndex].apply(socket, attempt0.isTlsFallback) assertEnabledProtocols(socket, TlsVersion.TLS_1_2) - val attempt1 = attempt0.nextConnectionSpec(connectionSpecs, socket) + val attempt1 = attempt0.nextConnectionSpec(connectionSpecs, socket, retryableException) assertThat(attempt1).isNotNull() assertThat(attempt1!!.isTlsFallback).isTrue() socket.close() @@ -110,7 +223,7 @@ class RetryConnectionTest { assertEnabledProtocols(socket, TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0) } - val attempt2 = attempt1.nextConnectionSpec(connectionSpecs, socket) + val attempt2 = attempt1.nextConnectionSpec(connectionSpecs, socket, retryableException) assertThat(attempt2).isNull() socket.close() diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RouteSelectorTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RouteSelectorTest.kt index 10ef278e2319..38c4e52b30c5 100644 --- a/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RouteSelectorTest.kt +++ b/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RouteSelectorTest.kt @@ -18,6 +18,7 @@ package okhttp3.internal.connection import app.cash.burst.Burst import assertk.assertThat import assertk.assertions.containsExactly +import assertk.assertions.hasSize import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isSameInstanceAs @@ -133,6 +134,43 @@ class RouteSelectorTest( dns.assertRequests(uriHost) } + /** https://www.rfc-editor.org/rfc/rfc9848.html#section-5.1 */ + @Test fun echAddressesDoNotFallBackToNonEch() { + assumeTrue(entryPoint == EntryPoint.NewCall) + + val echAddress = dns.allocate(1).single() + val nonEchAddress = dns.allocate(1).single() + dns[uriHost] = + listOf( + ResourceRecord.Https( + name = uriHost, + timeToLive = 5, + targetName = "ech.$uriHost", + echConfigList = echConfigList, + ), + ResourceRecord.IpAddress( + name = "ech.$uriHost", + timeToLive = 5, + address = echAddress, + ), + ResourceRecord.IpAddress( + name = "non-ech.$uriHost", + timeToLive = 5, + address = nonEchAddress, + ), + ) + val address = factory.newAddress() + val routeSelector = newRouteSelector(address) + + val selection = routeSelector.next() + + assertThat(selection.routes).hasSize(1) + assertRoute(selection.next(), address, Proxy.NO_PROXY, echAddress, uriPort, echConfigList) + assertThat(selection.hasNext()).isFalse() + assertThat(routeSelector.hasNext()).isFalse() + dns.assertRequests(uriHost) + } + @Test fun singleRouteReturnsFailedRoute() { val address = factory.newAddress() var routeSelector = newRouteSelector(address)