Skip to content

Commit 3ce4ae7

Browse files
committed
fix:add support for idle connection monitoring (opt-in for now)
1 parent b5f5e91 commit 3ce4ae7

File tree

8 files changed

+255
-2
lines changed

8 files changed

+255
-2
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"id": "386353e6-e3cc-4561-bfd6-9763d3ac033b",
3+
"type": "bugfix",
4+
"description": "Add support for connection idle monitoring for OkHttp via the engine config parameter `connectionIdlePollingInterval`. Monitoring is disabled by default to match previous behavior. This monitoring will switch to enabled by default in an upcoming minor version release.",
5+
"issues": [
6+
"awslabs/aws-sdk-kotlin#1214"
7+
]
8+
}

runtime/protocol/http-client-engines/http-client-engine-okhttp/api/http-client-engine-okhttp.api

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,17 @@ public final class aws/smithy/kotlin/runtime/http/engine/okhttp/OkHttpEngine$Com
6666
public final class aws/smithy/kotlin/runtime/http/engine/okhttp/OkHttpEngineConfig : aws/smithy/kotlin/runtime/http/engine/HttpClientEngineConfigImpl {
6767
public static final field Companion Laws/smithy/kotlin/runtime/http/engine/okhttp/OkHttpEngineConfig$Companion;
6868
public synthetic fun <init> (Laws/smithy/kotlin/runtime/http/engine/okhttp/OkHttpEngineConfig$Builder;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
69+
public final fun getConnectionIdlePollingInterval-FghU774 ()Lkotlin/time/Duration;
6970
public final fun getMaxConcurrencyPerHost-pVg5ArA ()I
7071
public fun toBuilderApplicator ()Lkotlin/jvm/functions/Function1;
7172
}
7273

7374
public final class aws/smithy/kotlin/runtime/http/engine/okhttp/OkHttpEngineConfig$Builder : aws/smithy/kotlin/runtime/http/engine/HttpClientEngineConfigImpl$BuilderImpl {
7475
public fun <init> ()V
76+
public final fun getConnectionIdlePollingInterval-FghU774 ()Lkotlin/time/Duration;
7577
public final fun getMaxConcurrencyPerHost-0hXNFcg ()Lkotlin/UInt;
7678
public fun getTelemetryProvider ()Laws/smithy/kotlin/runtime/telemetry/TelemetryProvider;
79+
public final fun setConnectionIdlePollingInterval-BwNAW2A (Lkotlin/time/Duration;)V
7780
public final fun setMaxConcurrencyPerHost-ExVfyTY (Lkotlin/UInt;)V
7881
public fun setTelemetryProvider (Laws/smithy/kotlin/runtime/telemetry/TelemetryProvider;)V
7982
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package aws.smithy.kotlin.runtime.http.engine.okhttp
6+
7+
import aws.smithy.kotlin.runtime.telemetry.logging.Logger
8+
import aws.smithy.kotlin.runtime.telemetry.logging.logger
9+
import kotlinx.coroutines.CoroutineName
10+
import kotlinx.coroutines.CoroutineScope
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.Job
13+
import kotlinx.coroutines.cancelAndJoin
14+
import kotlinx.coroutines.isActive
15+
import kotlinx.coroutines.launch
16+
import kotlinx.coroutines.runBlocking
17+
import okhttp3.Call
18+
import okhttp3.Connection
19+
import okhttp3.ConnectionListener
20+
import okhttp3.ExperimentalOkHttpApi
21+
import okhttp3.internal.closeQuietly
22+
import okio.EOFException
23+
import okio.buffer
24+
import okio.source
25+
import java.net.SocketException
26+
import java.net.SocketTimeoutException
27+
import java.util.concurrent.ConcurrentHashMap
28+
import kotlin.coroutines.coroutineContext
29+
import kotlin.time.Duration
30+
import kotlin.time.Duration.Companion.seconds
31+
import kotlin.time.measureTime
32+
33+
@OptIn(ExperimentalOkHttpApi::class)
34+
internal class ConnectionIdleMonitor(val pollInterval: Duration) : ConnectionListener() {
35+
private val monitors = ConcurrentHashMap<Connection, Job>()
36+
37+
private fun Call.callContext() =
38+
request()
39+
.tag(SdkRequestTag::class.java)
40+
?.callContext
41+
?: Dispatchers.IO
42+
43+
override fun connectionAcquired(connection: Connection, call: Call) {
44+
// Non-locking map access is okay here because this code will only execute synchronously as part of a
45+
// `connectionAcquired` event and will be complete before any future `connectionReleased` event could fire for
46+
// the same connection.
47+
monitors.remove(connection)?.let { monitor ->
48+
5.seconds
49+
val context = call.callContext()
50+
val logger = context.logger<ConnectionIdleMonitor>()
51+
logger.trace { "Cancel monitoring for $connection" }
52+
53+
// Use `runBlocking` because this _must_ finish before OkHttp goes to use the connection
54+
val cancelTime = measureTime {
55+
runBlocking(context) { monitor.cancelAndJoin() }
56+
}
57+
58+
logger.trace { "Monitoring canceled for $connection in $cancelTime" }
59+
}
60+
}
61+
62+
override fun connectionReleased(connection: Connection, call: Call) {
63+
val connId = System.identityHashCode(connection)
64+
val context = call.callContext()
65+
val scope = CoroutineScope(context)
66+
val logger = context.logger<ConnectionIdleMonitor>()
67+
val monitor = scope.launch(CoroutineName("okhttp-conn-monitor-for-$connId")) {
68+
doMonitor(connection, logger)
69+
}
70+
logger.trace { "Launched coroutine $monitor to monitor $connection" }
71+
72+
// Non-locking map access is okay here because this code will only execute synchronously as part of a
73+
// `connectionReleased` event and will be complete before any future `connectionAcquired` event could fire for
74+
// the same connection.
75+
monitors[connection] = monitor
76+
}
77+
78+
private suspend fun doMonitor(conn: Connection, logger: Logger) {
79+
val socket = conn.socket()
80+
val source = try {
81+
socket.source()
82+
} catch (_: SocketException) {
83+
logger.trace { "Socket for $conn closed before monitoring started. Skipping polling loop." }
84+
return
85+
}.buffer().peek()
86+
87+
logger.trace { "Commence socket monitoring for $conn" }
88+
var resetTimeout = true
89+
val oldTimeout = socket.soTimeout
90+
91+
try {
92+
socket.soTimeout = pollInterval.inWholeMilliseconds.toInt()
93+
94+
while (coroutineContext.isActive) {
95+
try {
96+
source.readByte() // Blocking read; will take up to READ_TIMEOUT_MS to complete
97+
} catch (_: SocketTimeoutException) {
98+
// Socket is still alive
99+
} catch (_: EOFException) {
100+
logger.trace { "Socket for $conn was closed remotely" }
101+
socket.closeQuietly()
102+
resetTimeout = false
103+
return
104+
}
105+
}
106+
} catch (e: Throwable) {
107+
logger.warn(e) { "Failed to poll $conn. Ending polling loop. Connection may be unstable now." }
108+
} finally {
109+
if (resetTimeout) {
110+
logger.trace { "Attempting to reset soTimeout..." }
111+
try {
112+
conn.socket().soTimeout = oldTimeout
113+
} catch (e: Throwable) {
114+
logger.warn(e) { "Failed to reset socket timeout on $conn. Connection may be unstable now." }
115+
}
116+
}
117+
}
118+
}
119+
}

runtime/protocol/http-client-engines/http-client-engine-okhttp/jvm/src/aws/smithy/kotlin/runtime/http/engine/okhttp/OkHttpEngine.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,20 @@ public fun OkHttpEngineConfig.buildClient(metrics: HttpClientMetrics): OkHttpCli
9999
readTimeout(config.socketReadTimeout.toJavaDuration())
100100
writeTimeout(config.socketWriteTimeout.toJavaDuration())
101101

102-
// FIXME - register a [ConnectionListener](https://github.com/square/okhttp/blob/master/okhttp/src/jvmMain/kotlin/okhttp3/ConnectionListener.kt#L27)
103-
// when a new okhttp release is cut that contains this abstraction and wireup connection uptime metrics
102+
@OptIn(ExperimentalOkHttpApi::class)
103+
val connectionListener = if (config.connectionIdlePollingInterval == null) {
104+
ConnectionListener.NONE
105+
} else {
106+
ConnectionIdleMonitor(connectionIdlePollingInterval)
107+
}
104108

105109
// use our own pool configured with the timeout settings taken from config
110+
@OptIn(ExperimentalOkHttpApi::class)
106111
val pool = ConnectionPool(
107112
maxIdleConnections = 5, // The default from the no-arg ConnectionPool() constructor
108113
keepAliveDuration = config.connectionIdleTimeout.inWholeMilliseconds,
109114
TimeUnit.MILLISECONDS,
115+
connectionListener = connectionListener,
110116
)
111117
connectionPool(pool)
112118

runtime/protocol/http-client-engines/http-client-engine-okhttp/jvm/src/aws/smithy/kotlin/runtime/http/engine/okhttp/OkHttpEngineConfig.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import aws.smithy.kotlin.runtime.http.engine.HttpClientEngineConfig
99
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngineConfigImpl
1010
import aws.smithy.kotlin.runtime.telemetry.Global
1111
import aws.smithy.kotlin.runtime.telemetry.TelemetryProvider
12+
import kotlin.time.Duration
1213

1314
/**
1415
* The configuration parameters for an OkHttp HTTP client engine.
@@ -28,6 +29,22 @@ public class OkHttpEngineConfig private constructor(builder: Builder) : HttpClie
2829
public val Default: OkHttpEngineConfig = OkHttpEngineConfig(Builder())
2930
}
3031

32+
/**
33+
* The interval in which to poll idle connections for remote closure or `null` to disable monitoring of idle
34+
* connections. The default value is `null`.
35+
*
36+
* When this value is non-`null`, polling is enabled on connections which are released from an engine call and
37+
* enter the connection pool. Polling consists of a loop that performs blocking reads with the socket timeout
38+
* set to [connectionIdlePollingInterval]. Polling is cancelled for a connection when the engine acquires it
39+
* from the pool or when the connection is evicted from the pool and closed. Because the polling loop uses
40+
* blocking reads, an engine call to acquire or close a connection may be delayed by as much as
41+
* [connectionIdlePollingInterval].
42+
*
43+
* When this value is `null`, polling is disabled. Idle connections in the pool which are closed remotely may
44+
* encounter errors when they are acquired for a subsequent call.
45+
*/
46+
public val connectionIdlePollingInterval: Duration? = builder.connectionIdlePollingInterval
47+
3148
/**
3249
* The maximum number of requests to execute concurrently for a single host.
3350
*/
@@ -37,6 +54,7 @@ public class OkHttpEngineConfig private constructor(builder: Builder) : HttpClie
3754
super.toBuilderApplicator()()
3855

3956
if (this is Builder) {
57+
connectionIdlePollingInterval = this@OkHttpEngineConfig.connectionIdlePollingInterval
4058
maxConcurrencyPerHost = this@OkHttpEngineConfig.maxConcurrencyPerHost
4159
}
4260
}
@@ -45,6 +63,22 @@ public class OkHttpEngineConfig private constructor(builder: Builder) : HttpClie
4563
* A builder for [OkHttpEngineConfig]
4664
*/
4765
public class Builder : BuilderImpl() {
66+
/**
67+
* The interval in which to poll idle connections for remote closure or `null` to disable monitoring of idle
68+
* connections. The default value is `null`.
69+
*
70+
* When this value is non-`null`, polling is enabled on connections which are released from an engine call and
71+
* enter the connection pool. Polling consists of a loop that performs blocking reads with the socket timeout
72+
* set to [connectionIdlePollingInterval]. Polling is cancelled for a connection when the engine acquires it
73+
* from the pool or when the connection is evicted from the pool and closed. Because the polling loop uses
74+
* blocking reads, an engine call to acquire or close a connection may be delayed by as much as
75+
* [connectionIdlePollingInterval].
76+
*
77+
* When this value is `null`, polling is disabled. Idle connections in the pool which are closed remotely may
78+
* encounter errors when they are acquired for a subsequent call.
79+
*/
80+
public var connectionIdlePollingInterval: Duration? = null
81+
4882
/**
4983
* The maximum number of requests to execute concurrently for a single host. Defaults to [maxConcurrency].
5084
*/
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package aws.smithy.kotlin.runtime.http.test.suite
6+
7+
import io.ktor.server.application.Application
8+
import io.ktor.server.application.call
9+
import io.ktor.server.jetty.JettyApplicationCall
10+
import io.ktor.server.response.respondText
11+
import io.ktor.server.routing.RoutingApplicationCall
12+
import io.ktor.server.routing.post
13+
import io.ktor.server.routing.route
14+
import io.ktor.server.routing.routing
15+
import io.ktor.util.InternalAPI
16+
import io.ktor.utils.io.close
17+
import kotlinx.coroutines.delay
18+
import kotlinx.coroutines.launch
19+
import kotlin.time.Duration.Companion.seconds
20+
21+
internal fun Application.connectionTests() {
22+
routing {
23+
route("connectionDrop") {
24+
@OptIn(InternalAPI::class)
25+
post {
26+
val routingCall = call as RoutingApplicationCall
27+
val jettyCall = routingCall.engineCall as JettyApplicationCall
28+
29+
launch {
30+
delay(4.seconds)
31+
jettyCall.response.responseChannel().close()
32+
}
33+
34+
jettyCall.respondText("Bar")
35+
}
36+
}
37+
}
38+
}

runtime/protocol/http-client-engines/test-suite/jvm/src/aws/smithy/kotlin/runtime/http/test/util/TestServers.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ internal fun Application.testRoutes() {
124124
uploadTests()
125125
concurrentTests()
126126
headerTests()
127+
connectionTests()
127128
}
128129

129130
// configure SSL-only routes

runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/ConnectionTest.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@ package aws.smithy.kotlin.runtime.http.test
66

77
import aws.smithy.kotlin.runtime.content.decodeToString
88
import aws.smithy.kotlin.runtime.http.*
9+
import aws.smithy.kotlin.runtime.http.engine.okhttp.OkHttpEngineConfig
910
import aws.smithy.kotlin.runtime.http.request.HttpRequest
1011
import aws.smithy.kotlin.runtime.http.request.url
1112
import aws.smithy.kotlin.runtime.http.test.util.*
1213
import aws.smithy.kotlin.runtime.http.test.util.testServers
1314
import aws.smithy.kotlin.runtime.net.TlsVersion
15+
import kotlinx.coroutines.delay
1416
import java.nio.file.Paths
1517
import kotlin.test.*
18+
import kotlin.time.Duration.Companion.milliseconds
19+
import kotlin.time.Duration.Companion.seconds
1620

1721
class ConnectionTest : AbstractEngineTest() {
1822
private fun testMinTlsVersion(version: TlsVersion, serverType: ServerType) {
@@ -79,4 +83,44 @@ class ConnectionTest : AbstractEngineTest() {
7983

8084
@Test
8185
fun testMinTls1_3() = testMinTlsVersion(TlsVersion.TLS_1_3, ServerType.TLS_1_3)
86+
87+
@Test
88+
fun testShortLivedConnections() = testEngines(
89+
// Only run this test on OkHttp
90+
skipEngines = setOf("CrtHttpEngine", "OkHttp4Engine"),
91+
) {
92+
engineConfig {
93+
this as OkHttpEngineConfig.Builder
94+
connectionIdlePollingInterval = 200.milliseconds
95+
connectionIdleTimeout = 10.seconds // Longer than the server-side timeout
96+
}
97+
98+
test { _, client ->
99+
val initialReq = HttpRequest {
100+
testSetup()
101+
method = HttpMethod.POST
102+
url {
103+
path.decoded = "/connectionDrop"
104+
}
105+
body = "Foo".toHttpBody()
106+
}
107+
val initialCall = client.call(initialReq)
108+
val initialResp = initialCall.response.body.toByteStream()?.decodeToString()
109+
assertEquals("Bar", initialResp)
110+
111+
delay(5.seconds) // Shorter than the client-side timeout
112+
113+
val subsequentReq = HttpRequest {
114+
testSetup()
115+
method = HttpMethod.POST
116+
url {
117+
path.decoded = "/connectionDrop"
118+
}
119+
body = "Foo".toHttpBody()
120+
}
121+
val subsequentCall = client.call(subsequentReq)
122+
val subsequentResp = subsequentCall.response.body.toByteStream()?.decodeToString()
123+
assertEquals("Bar", subsequentResp)
124+
}
125+
}
82126
}

0 commit comments

Comments
 (0)