Skip to content

Commit b3704ff

Browse files
authored
feat(rt): introduce opaque KMP default HTTP client engine (#606)
Deprecate KtorEngine as synonymous with OkHttp. The `http-client-engine-ktor` is now a utility wrapper for _any_ Ktor compliant engine. The OkHttp engine has been moved to `http-client-engine-default` as the default engine on JVM. BREAKING CHANGE: Use of explicit `httpClientEngine = KtorEngine()` will no longer compile. The default engine will be synonymous with OkHttp making it unnecessary to set this engine. Users should remove this explicit SDK client configuration.
1 parent 656c95a commit b3704ff

File tree

22 files changed

+478
-391
lines changed

22 files changed

+478
-391
lines changed

runtime/io/common/src/aws/smithy/kotlin/runtime/io/KtorAdapters.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
package aws.smithy.kotlin.runtime.io
77

8+
import aws.smithy.kotlin.runtime.util.InternalApi
89
import io.ktor.utils.io.*
910
import io.ktor.utils.io.core.*
1011
import io.ktor.utils.io.ByteChannel as KtorByteChannel
@@ -100,6 +101,11 @@ internal class KtorByteChannelAdapter(
100101
internal expect class KtorReadChannelAdapter(chan: KtorByteReadChannel) : SdkByteReadChannel
101102
internal expect class KtorWriteChannelAdapter(chan: KtorByteWriteChannel) : SdkByteWriteChannel
102103

103-
internal fun KtorByteReadChannel.toSdkChannel(): SdkByteReadChannel = KtorReadChannelAdapter(this)
104-
internal fun KtorByteWriteChannel.toSdkChannel(): SdkByteWriteChannel = KtorWriteChannelAdapter(this)
105-
internal fun KtorByteChannel.toSdkChannel(): SdkByteChannel = KtorByteChannelAdapter(this)
104+
@InternalApi
105+
fun KtorByteReadChannel.toSdkChannel(): SdkByteReadChannel = KtorReadChannelAdapter(this)
106+
107+
@InternalApi
108+
fun KtorByteWriteChannel.toSdkChannel(): SdkByteWriteChannel = KtorWriteChannelAdapter(this)
109+
110+
@InternalApi
111+
fun KtorByteChannel.toSdkChannel(): SdkByteChannel = KtorByteChannelAdapter(this)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
description = "Default HTTP Client Engine for Smithy services generated by smithy-kotlin"
7+
extra["moduleName"] = "aws.smithy.kotlin.runtime.http.engine"
8+
9+
val ktorVersion: String by project
10+
11+
kotlin {
12+
sourceSets {
13+
commonMain {
14+
dependencies {
15+
api(project(":runtime:protocol:http"))
16+
}
17+
}
18+
jvmMain {
19+
dependencies {
20+
implementation(project(":runtime:protocol:http-client-engines:http-client-engine-ktor"))
21+
// okhttp works on both JVM and Android
22+
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
23+
}
24+
}
25+
all {
26+
languageSettings.optIn("aws.smithy.kotlin.runtime.util.InternalApi")
27+
}
28+
}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.smithy.kotlin.runtime.http.engine
7+
8+
/**
9+
* Factory function to create a new HTTP client engine using the default for the current KMP target
10+
*/
11+
fun DefaultHttpEngine(config: HttpClientEngineConfig = HttpClientEngineConfig.Default): HttpClientEngine =
12+
newDefaultHttpEngine(config)
13+
14+
fun DefaultHttpEngine(block: HttpClientEngineConfig.Builder.() -> Unit): HttpClientEngine {
15+
val builder = HttpClientEngineConfig.Builder().apply(block)
16+
val config = HttpClientEngineConfig(builder)
17+
return DefaultHttpEngine(config)
18+
}
19+
20+
internal expect fun newDefaultHttpEngine(config: HttpClientEngineConfig): HttpClientEngine
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.smithy.kotlin.runtime.http.engine
7+
8+
import aws.smithy.kotlin.runtime.http.engine.ktor.KtorEngine
9+
import io.ktor.client.engine.okhttp.*
10+
import okhttp3.ConnectionPool
11+
import okhttp3.Protocol
12+
import java.util.concurrent.TimeUnit
13+
import kotlin.time.toJavaDuration
14+
15+
internal actual fun newDefaultHttpEngine(config: HttpClientEngineConfig): HttpClientEngine {
16+
val okHttpEngine = OkHttp.create {
17+
config {
18+
connectTimeout(config.connectTimeout.toJavaDuration())
19+
readTimeout(config.socketReadTimeout.toJavaDuration())
20+
writeTimeout(config.socketWriteTimeout.toJavaDuration())
21+
val pool = ConnectionPool(
22+
maxIdleConnections = config.maxConnections.toInt(),
23+
keepAliveDuration = config.connectionIdleTimeout.inWholeMilliseconds,
24+
TimeUnit.MILLISECONDS
25+
)
26+
connectionPool(pool)
27+
28+
if (config.alpn.isNotEmpty()) {
29+
val protocols = config.alpn.mapNotNull {
30+
when (it) {
31+
AlpnId.HTTP1_1 -> Protocol.HTTP_1_1
32+
AlpnId.HTTP2 -> Protocol.HTTP_2
33+
else -> null
34+
}
35+
}
36+
protocols(protocols)
37+
}
38+
}
39+
}
40+
41+
return KtorEngine(okHttpEngine)
42+
}

runtime/protocol/http-client-engines/http-client-engine-ktor/build.gradle.kts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,14 @@ kotlin {
1515
commonMain {
1616
dependencies {
1717
api(project(":runtime:protocol:http"))
18-
}
19-
}
20-
jvmMain {
21-
dependencies {
22-
implementation(project(":runtime:io"))
23-
// okhttp works on both JVM and Android
24-
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
2518
implementation(project(":runtime:logging"))
19+
implementation(project(":runtime:io"))
20+
21+
// exposes HttpClientEngine interface wrapped by KtorEngine
22+
api("io.ktor:ktor-client-core:$ktorVersion")
2623
}
2724
}
28-
jvmTest {
25+
commonTest {
2926
dependencies {
3027
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
3128
}

runtime/protocol/http-client-engines/http-client-engine-ktor/common/src/aws/smithy/kotlin/runtime/http/engine/ktor/KtorEngine.kt

Lines changed: 150 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,157 @@
44
*/
55
package aws.smithy.kotlin.runtime.http.engine.ktor
66

7-
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngine
8-
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngineBase
9-
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngineConfig
7+
import aws.smithy.kotlin.runtime.http.Headers
8+
import aws.smithy.kotlin.runtime.http.HttpStatusCode
9+
import aws.smithy.kotlin.runtime.http.engine.*
10+
import aws.smithy.kotlin.runtime.http.request.HttpRequest
11+
import aws.smithy.kotlin.runtime.http.response.HttpCall
12+
import aws.smithy.kotlin.runtime.http.response.HttpResponse
13+
import aws.smithy.kotlin.runtime.logging.Logger
14+
import aws.smithy.kotlin.runtime.logging.trace
15+
import aws.smithy.kotlin.runtime.time.Instant
16+
import io.ktor.client.*
17+
import io.ktor.client.request.*
18+
import io.ktor.client.statement.*
19+
import io.ktor.http.*
20+
import io.ktor.util.*
21+
import kotlinx.coroutines.CoroutineDispatcher
22+
import kotlinx.coroutines.channels.Channel
23+
import kotlinx.coroutines.channels.SendChannel
24+
import kotlinx.coroutines.job
25+
import kotlinx.coroutines.launch
26+
import kotlinx.coroutines.sync.Mutex
27+
import kotlin.coroutines.CoroutineContext
28+
import io.ktor.client.engine.HttpClientEngine as KtorHttpClientEngine
1029

1130
/**
12-
* Specifies the ktor http client of which platform specific [HttpClientEngine]'s actualize
13-
*
14-
* @param config Provides configuration for the DefaultHttpClientEngine
31+
* Utility class that wraps the given Ktor engine as an [HttpClientEngine].
32+
* This class can be used to wrap any Ktor compliant engine (though not all engines
33+
* may support HTTP features required by any given SDK).
1534
*/
16-
expect class KtorEngine(config: HttpClientEngineConfig = HttpClientEngineConfig.Default) : HttpClientEngineBase {
17-
val config: HttpClientEngineConfig
35+
class KtorEngine(
36+
private val engine: KtorHttpClientEngine
37+
) : HttpClientEngineBase("ktor") {
38+
39+
@Suppress("UNUSED_PARAMETER")
40+
@Deprecated(
41+
message = "KtorEngine was previously synonymous with the OkHttp engine. It has been modified to wrap any " +
42+
"Ktor compliant engine. The default engine has been changed from CRT to Ktor/OkHttp. To fix either " +
43+
"remove setting `httpClientEngine` explicitly or instantiate a Ktor compliant engine of your own and " +
44+
"use KtorEngine to wrap it. This constructor will be removed in a future release before GA.",
45+
level = DeprecationLevel.ERROR
46+
)
47+
constructor(config: HttpClientEngineConfig = HttpClientEngineConfig.Default) : this(DeprecationEngine)
48+
49+
val client: HttpClient = HttpClient(engine) {
50+
// do not throw exceptions if status code < 300, error handling is expected by generated clients
51+
expectSuccess = false
52+
53+
// do not attempt to follow redirects for status codes like 301 because they should be handled higher up
54+
followRedirects = false
55+
}
56+
57+
private val logger = Logger.getLogger<KtorEngine>()
58+
59+
override suspend fun roundTrip(request: HttpRequest): HttpCall {
60+
val callContext = callContext()
61+
62+
val respChannel = Channel<HttpCall>(Channel.RENDEZVOUS)
63+
64+
// run the request in another coroutine to allow streaming body to be handled
65+
launch(callContext + ioDispatcher()) {
66+
try {
67+
execute(callContext, request, respChannel)
68+
} catch (ex: Exception) {
69+
// signal the HTTP response isn't coming
70+
respChannel.close(ex)
71+
}
72+
}
73+
74+
// wait for the response to be available, the content will be read as a stream
75+
logger.trace("waiting on response to be available")
76+
77+
try {
78+
val resp = respChannel.receive()
79+
logger.trace("response is available continuing")
80+
return resp
81+
} catch (ex: Exception) {
82+
throw logger.throwing(ex)
83+
}
84+
}
85+
86+
private suspend fun execute(
87+
callContext: CoroutineContext,
88+
sdkRequest: HttpRequest,
89+
channel: SendChannel<HttpCall>
90+
) {
91+
val builder = KtorRequestAdapter(sdkRequest, callContext).toBuilder()
92+
val waiter = Waiter()
93+
val reqTime = Instant.now()
94+
client.request<HttpStatement>(builder).execute { httpResp ->
95+
val respTime = Instant.now()
96+
// we have a lifetime problem here...the stream (and HttpResponse instance) are only valid
97+
// until the end of this block. We don't know if the consumer wants to read the content fully or
98+
// stream it. We need to wait until the entire content has been read before leaving the block and
99+
// releasing the underlying network resources. We do this by blocking until the request job
100+
// completes, at which point we signal it's safe to exit the block and release the underlying resources.
101+
callContext.job.invokeOnCompletion { waiter.signal() }
102+
103+
val body = KtorHttpBody(httpResp.contentLength(), httpResp.content)
104+
105+
// copy the headers so that we no longer depend on the underlying ktor HttpResponse object
106+
// outside of the body content (which will signal once read that it is safe to exit the block)
107+
val headers = Headers { appendAll(KtorHeaders(httpResp.headers)) }
108+
109+
val resp = HttpResponse(
110+
HttpStatusCode.fromValue(httpResp.status.value),
111+
headers,
112+
body,
113+
)
114+
115+
logger.trace("signalling response")
116+
val call = HttpCall(sdkRequest, resp, reqTime, respTime, callContext)
117+
channel.send(call)
118+
119+
logger.trace("waiting on body to be consumed")
120+
// wait for the receiving end to finish with the HTTP body
121+
waiter.wait()
122+
logger.trace("request done")
123+
}
124+
}
125+
126+
override fun close() {
127+
client.close()
128+
engine.close()
129+
}
130+
}
131+
132+
/**
133+
* Simple notify mechanism that waits for a signal
134+
*/
135+
internal class Waiter {
136+
private val mutex = Mutex(locked = true)
137+
138+
// wait for the signal
139+
suspend fun wait() { mutex.lock() }
140+
141+
// give the signal to continue
142+
fun signal() { mutex.unlock() }
143+
}
144+
145+
// FIXME - dummy engine for deprecated constructor, remove before GA
146+
private object DeprecationEngine : KtorHttpClientEngine {
147+
override val config: io.ktor.client.engine.HttpClientEngineConfig
148+
get() = error("not a real engine")
149+
override val coroutineContext: CoroutineContext
150+
get() = error("not a real engine")
151+
override val dispatcher: CoroutineDispatcher
152+
get() = error("not a real engine")
153+
154+
override fun close() {}
155+
156+
@InternalAPI
157+
override suspend fun execute(data: HttpRequestData): HttpResponseData {
158+
error("not a real engine")
159+
}
18160
}

0 commit comments

Comments
 (0)