Changelog 3.5 version
3.5.0
released 18th May 2026
Client
DNS configuration for the Apache5 client
Add DNS server configuration for Apache5 client.
DNS configuration for OkHttp client engine
Currently, there is no means for configuring DNS settings on the OkHttp engine. We can introduce this feature to delegate to the OkHttp API.
Curl: Can't build shared library with Ktor 3.4.2
After upgrading Ktor from 3.3.3 to 3.4.2 I get the following error:
e: /home/thomas/.konan/dependencies/llvm-19-x86_64-linux-essentials-109/bin/ld.lld invocation reported errors
The /home/thomas/.konan/dependencies/llvm-19-x86_64-linux-essentials-109/bin/ld.lld command returned non-zero exit code: 1.
output:
ld.lld: error: relocation R_X86_64_PC32 cannot be used against symbol 'nghttp2_enable_strict_preface'; recompile with -fPIC
>>> defined in /tmp/included8642869110146422869/libnghttp2.a(nghttp2_session.c.o)
>>> referenced by nghttp2_session.c
>>> nghttp2_session.c.o:(session_new) in archive /tmp/included8642869110146422869/libnghttp2.a
ld.lld: error: relocation R_X86_64_PC32 cannot be used against symbol 'nghttp2_stream_root'; recompile with -fPIC
>>> defined in /tmp/included8642869110146422869/libnghttp2.a(nghttp2_session.c.o)
>>> referenced by nghttp2_session.c
>>> nghttp2_session.c.o:(nghttp2_session_find_stream) in archive /tmp/included8642869110146422869/libnghttp2.a
ld.lld: error: relocation R_X86_64_PC32 cannot be used against symbol 'nghttp2_stream_root'; recompile with -fPIC
>>> defined in /tmp/included8642869110146422869/libnghttp2.a(nghttp2_session.c.o)
>>> referenced by nghttp2_session.c
>>> nghttp2_session.c.o:(nghttp2_session_get_root_stream) in archive /tmp/included8642869110146422869/libnghttp2.a
> Task :desktop-native:linkReleaseSharedLinuxX64 FAILED
This is on the linuxX64 target. I'm also using the Ktor curl module on Linux which seems related?
Kotlin version: 2.3.20
Darwin throws DarwinHttpRequestException instead of FrameTooBigException
All JVM engines throw FrameTooBigException, so Darwin should also throw this exception making it possible to handle the exception from common code.
Expected an exception of io.ktor.websocket.FrameTooBigException to be thrown, but was
io.ktor.client.engine.darwin.DarwinHttpRequestException: Exception in http request: Error Domain=NSPOSIXErrorDomain Code=40 "Message too long" UserInfo={NSDescription=Message too long, _NSURLErrorRelatedURLSessionTaskErrorKey=(
"LocalWebSocketTask <DBE9C361-01EB-4E55-A2DB-A39F6EB64265>.<1>"
), _NSURLErrorFailingURLSessionTaskErrorKey=LocalWebSocketTask <DBE9C361-01EB-4E55-A2DB-A39F6EB64265>.<1>}
Curl: backpressure implementation is never used
Background
Current runBlocking implementation accidentally provides backpressure by blocking the curl multi-handle thread inside runBlocking { writeFully() } — but this is the wrong mechanism, as it stalled all in-flight requests, not just the one that needed throttling.
Desired behavior
When the response ByteChannel buffer reaches CHANNEL_MAX_SIZE (1 MB), the curl engine should return WRITEFUNC_PAUSE from the write callback to pause the specific easy handle. When the consumer drains enough data, curl_easy_pause(CURLPAUSE_CONT) should resume it.
This is the correct curl backpressure mechanism, already used on the upload path (CurlRequestBodyData).
Design notes
The challenge is that onBodyChunkReceived is a synchronous C callback — it cannot suspend. Two approaches were explored:
Approach 1: availableForWrite property on ByteChannel
Add @InternalAPI val availableForWrite: Int to ByteChannel (class only, not the BufferedByteWriteChannel interface — no ABI breakage). The callback checks this before writing:
bodyChannel.writeBuffer.writeFully(buffer, 0L, chunkSize.toLong())
bodyChannel.flushWriteBuffer()
if (bodyChannel.availableForWrite > 0) return chunkSize.convert() // fast path, zero alloc
// slow path: buffer full
paused = true
scope.launch {
bodyChannel.flush() // suspends until drained
paused = false
onUnpause()
}
return chunkSize.convert()
Zero allocations on the fast path.
Approach 2: startCoroutineUninterceptedOrReturn on a reusable lambda
No ktor-io changes needed. Make the class implement Continuation<Unit> and store the flush lambda as a val (created once, captures only this):
private val awaitFreeSpace: suspend () -> Unit = { bodyChannel.flush() }
// In onBodyChunkReceived:
bodyChannel.writeBuffer.writeFully(buffer, 0L, chunkSize.toLong())
bodyChannel.flushWriteBuffer()
val outcome = awaitFreeSpace.startCoroutineUninterceptedOrReturn(this)
if (outcome === COROUTINE_SUSPENDED) paused = true
return chunkSize.convert()
One state machine allocation per chunk on the hot path (unavoidable without internal API). Combined with Approach 1's pre-check, the hot path becomes zero-alloc.
Notes
onUnpauseinfrastructure (easyHandlesToUnpause,unpauseEasyHandle) already exists inCurlMultiApiHandlerfor uploads — response bodies can reuse it- Darwin also lacks backpressure support (KTOR-9145)
Curl: Freeze when receiving large responses
CurlHttpResponseBody.onBodyChunkReceived uses runBlocking { bodyChannel.writeFully(...) } to bridge the libcurl write callback into coroutines. ByteChannel.flush() suspends when the unflushed buffer reaches 1 MB. Below that threshold the call returns immediately. Above it, flush() suspends the curl thread inside and waits for the consumer to drain the channel. Neither withTimeoutOrNull nor HttpTimeout can help because the curl thread is blocked inside runBlocking, not at a cancellable suspension point.
The fix direction is to remove runBlocking and make curl thread truly non-blocking.
Created from this comment
Websockets: Unable to close session with a custom CloseReason
I'm using client websocket plugin and trying to send custom CloseReason to server, here is part of my codes:
runCatching {
wsSession.close(CloseReason(4002, disconnectType.code.toString()))
}.onFailure {
it.printStackTrace()
}
wsSession is a connection session which type is DefaultClientWebSocketSession, and 4001 is custom close code, but server always got INTERNAL_ERROR code, here is Charles content:
and my ktor version is 3.1.2, i want to know why.
A client call wrapped with `withTimeout` throws a generic CancellationException instead of TimeoutCancellationException
Description
When using withTimeout or withTimeoutOrNull, the specific TimeoutCancellationException can get lost, as Ktor throws a CancellationException instead.
Repro
These tests
class KtorTest {
private val client = HttpClient()
@Test
fun withTimeoutOrNull() = runBlocking(Dispatchers.Default) {
val response = withTimeoutOrNull(1.milliseconds) {
client.get("https://ktor.io/")
}
assert(response == null) { "Expected a null value" }
}
@Test
fun withTimeout() = runBlocking(Dispatchers.Default) {
try {
withTimeout(1.milliseconds) {
client.get("https://ktor.io/")
}
fail("Expected a timeout")
} catch (e: TimeoutCancellationException) {
}
}
}
fail with
java.util.concurrent.CancellationException: Timed out waiting for 1 ms
at io.ktor.client.engine.UtilsKt$attachToUserJob$cleanupHandler$1.invoke(Utils.kt:108)
at io.ktor.client.engine.UtilsKt$attachToUserJob$cleanupHandler$1.invoke(Utils.kt:106)
at kotlinx.coroutines.InvokeOnCancelling.invoke(JobSupport.kt:1571)
at kotlinx.coroutines.JobSupport.invokeOnCompletionInternal$kotlinx_coroutines_core(JobSupport.kt:500)
at kotlinx.coroutines.JobSupport.invokeOnCompletion(JobSupport.kt:452)
at kotlinx.coroutines.Job$DefaultImpls.invokeOnCompletion$default(Job.kt:328)
at io.ktor.client.engine.HttpClientEngineKt.createCallContext(HttpClientEngine.kt:242)
at io.ktor.client.engine.HttpClientEngine.executeWithinCallContext(HttpClientEngine.kt:175)
at io.ktor.client.engine.HttpClientEngine.access$executeWithinCallContext(HttpClientEngine.kt:36)
at io.ktor.client.engine.HttpClientEngine$install$1.invokeSuspend(HttpClientEngine.kt:154)
at io.ktor.client.engine.HttpClientEngine$install$1.invoke(HttpClientEngine.kt)
at io.ktor.client.engine.HttpClientEngine$install$1.invoke(HttpClientEngine.kt)
at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
at io.ktor.util.pipeline.DebugPipelineContext.proceed(DebugPipelineContext.kt:57)
at io.ktor.util.pipeline.DebugPipelineContext.execute$ktor_utils(DebugPipelineContext.kt:63)
at io.ktor.util.pipeline.Pipeline.execute(Pipeline.kt:92)
at io.ktor.client.plugins.HttpSend$DefaultSender.execute(HttpSend.kt:137)
at io.ktor.client.plugins.api.Send$Sender.proceed(CommonHooks.kt:47)
at io.ktor.client.plugins.HttpRedirectKt$HttpRedirect$2$1.invokeSuspend(HttpRedirect.kt:112)
at io.ktor.client.plugins.HttpRedirectKt$HttpRedirect$2$1.invoke(HttpRedirect.kt)
at io.ktor.client.plugins.HttpRedirectKt$HttpRedirect$2$1.invoke(HttpRedirect.kt)
at io.ktor.client.plugins.api.Send$install$1.invokeSuspend(CommonHooks.kt:52)
at io.ktor.client.plugins.api.Send$install$1.invoke(CommonHooks.kt)
at io.ktor.client.plugins.api.Send$install$1.invoke(CommonHooks.kt)
at io.ktor.client.plugins.HttpSend$InterceptedSender.execute(HttpSend.kt:115)
at io.ktor.client.plugins.api.Send$Sender.proceed(CommonHooks.kt:47)
at io.ktor.client.plugins.HttpCallValidatorKt$HttpCallValidator$2$2.invokeSuspend(HttpCallValidator.kt:128)
at io.ktor.client.plugins.HttpCallValidatorKt$HttpCallValidator$2$2.invoke(HttpCallValidator.kt)
at io.ktor.client.plugins.HttpCallValidatorKt$HttpCallValidator$2$2.invoke(HttpCallValidator.kt)
at io.ktor.client.plugins.api.Send$install$1.invokeSuspend(CommonHooks.kt:52)
at io.ktor.client.plugins.api.Send$install$1.invoke(CommonHooks.kt)
at io.ktor.client.plugins.api.Send$install$1.invoke(CommonHooks.kt)
at io.ktor.client.plugins.HttpSend$InterceptedSender.execute(HttpSend.kt:115)
at io.ktor.client.plugins.HttpSend$Plugin$install$1.invokeSuspend(HttpSend.kt:103)
at io.ktor.client.plugins.HttpSend$Plugin$install$1.invoke(HttpSend.kt)
at io.ktor.client.plugins.HttpSend$Plugin$install$1.invoke(HttpSend.kt)
at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
at io.ktor.util.pipeline.DebugPipelineContext.proceed(DebugPipelineContext.kt:57)
at io.ktor.client.plugins.RequestError$install$1.invokeSuspend(HttpCallValidator.kt:150)
at io.ktor.client.plugins.RequestError$install$1.invoke(HttpCallValidator.kt)
at io.ktor.client.plugins.RequestError$install$1.invoke(HttpCallValidator.kt)
at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
at io.ktor.util.pipeline.DebugPipelineContext.proceed(DebugPipelineContext.kt:57)
at io.ktor.client.plugins.SetupRequestContext$install$1.invokeSuspend$proceed(HttpRequestLifecycle.kt:42)
at io.ktor.client.plugins.SetupRequestContext$install$1.access$invokeSuspend$proceed(HttpRequestLifecycle.kt)
at io.ktor.client.plugins.SetupRequestContext$install$1$1.invoke(HttpRequestLifecycle.kt:42)
at io.ktor.client.plugins.SetupRequestContext$install$1$1.invoke(HttpRequestLifecycle.kt:42)
at io.ktor.client.plugins.HttpRequestLifecycleKt$HttpRequestLifecycle$1$1.invokeSuspend(HttpRequestLifecycle.kt:29)
at io.ktor.client.plugins.HttpRequestLifecycleKt$HttpRequestLifecycle$1$1.invoke(HttpRequestLifecycle.kt)
at io.ktor.client.plugins.HttpRequestLifecycleKt$HttpRequestLifecycle$1$1.invoke(HttpRequestLifecycle.kt)
at io.ktor.client.plugins.SetupRequestContext$install$1.invokeSuspend(HttpRequestLifecycle.kt:42)
at io.ktor.client.plugins.SetupRequestContext$install$1.invoke(HttpRequestLifecycle.kt)
at io.ktor.client.plugins.SetupRequestContext$install$1.invoke(HttpRequestLifecycle.kt)
at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
at io.ktor.util.pipeline.DebugPipelineContext.proceed(DebugPipelineContext.kt:57)
at io.ktor.util.pipeline.DebugPipelineContext.execute$ktor_utils(DebugPipelineContext.kt:63)
at io.ktor.util.pipeline.Pipeline.execute(Pipeline.kt:92)
at io.ktor.client.HttpClient.execute$ktor_client_core(HttpClient.kt:1415)
at io.ktor.client.statement.HttpStatement.fetchResponse(HttpStatement.kt:163)
at io.ktor.client.statement.HttpStatement.execute(HttpStatement.kt:77)
at KtorTest$withTimeoutOrNull$1$response$1.invokeSuspend(KtorTest.kt:46)
at KtorTest$withTimeoutOrNull$1$response$1.invoke(KtorTest.kt)
at KtorTest$withTimeoutOrNull$1$response$1.invoke(KtorTest.kt)
at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndspatched(Undispatched.kt:66)
at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturnIgnoreTimeout(Undispatched.kt:50)
at kotlinx.coroutines.TimeoutKt.setupTimeout(Timeout.kt:149)
at kotlinx.coroutines.TimeoutKt.withTimeoutOrNull(Timeout.kt:105)
at kotlinx.coroutines.TimeoutKt.withTimeoutOrNull-KLykuaI(Timeout.kt:137)
at KtorTest$withTimeoutOrNull$1.invokeSuspend(KtorTest.kt:17)
at _COROUTINE._BOUNDARY._(CoroutineDebugging.kt:42)
at io.ktor.client.engine.HttpClientEngine.executeWithinCallContext(HttpClientEngine.kt:184)
at io.ktor.client.engine.HttpClientEngine$install$1.invokeSuspend(HttpClientEngine.kt:154)
at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
at io.ktor.client.plugins.HttpSend$DefaultSender.execute(HttpSend.kt:137)
at io.ktor.client.plugins.HttpRedirectKt$HttpRedirect$2$1.invokeSuspend(HttpRedirect.kt:112)
at io.ktor.client.plugins.api.Send$install$1.invokeSuspend(CommonHooks.kt:52)
at io.ktor.client.plugins.HttpCallValidatorKt$HttpCallValidator$2$2.invokeSuspend(HttpCallValidator.kt:128)
at io.ktor.client.plugins.api.Send$install$1.invokeSuspend(CommonHooks.kt:52)
at io.ktor.client.plugins.HttpSend$Plugin$install$1.invokeSuspend(HttpSend.kt:103)
at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
at io.ktor.client.plugins.RequestError$install$1.invokeSuspend(HttpCallValidator.kt:150)
at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
at io.ktor.client.plugins.HttpRequestLifecycleKt$HttpRequestLifecycle$1$1.invokeSuspend(HttpRequestLifecycle.kt:29)
at io.ktor.client.plugins.SetupRequestContext$install$1.invokeSuspend(HttpRequestLifecycle.kt:42)
at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
at io.ktor.client.HttpClient.execute$ktor_client_core(HttpClient.kt:1415)
at io.ktor.client.statement.HttpStatement.fetchResponse(HttpStatement.kt:163)
at KtorTest$withTimeoutOrNull$1$response$1.invokeSuspend(KtorTest.kt:46)
at kotlinx.coroutines.TimeoutKt.withTimeoutOrNull(Timeout.kt:102)
at KtorTest$withTimeoutOrNull$1.invokeSuspend(KtorTest.kt:17)
Caused by: java.util.concurrent.CancellationException: Timed out waiting for 1 ms
at io.ktor.client.engine.UtilsKt$attachToUserJob$cleanupHandler$1.invoke(Utils.kt:108)
at io.ktor.client.engine.UtilsKt$attachToUserJob$cleanupHandler$1.invoke(Utils.kt:106)
at kotlinx.coroutines.InvokeOnCancelling.invoke(JobSupport.kt:1571)
at kotlinx.coroutines.JobSupport.invokeOnCompletionInternal$kotlinx_coroutines_core(JobSupport.kt:500)
at kotlinx.coroutines.JobSupport.invokeOnCompletion(JobSupport.kt:452)
at kotlinx.coroutines.Job$DefaultImpls.invokeOnCompletion$default(Job.kt:328)
at io.ktor.client.engine.HttpClientEngineKt.createCallContext(HttpClientEngine.kt:242)
at io.ktor.client.engine.HttpClientEngine.executeWithinCallContext(HttpClientEngine.kt:175)
at io.ktor.client.engine.HttpClientEngine.access$executeWithinCallContext(HttpClientEngine.kt:36)
at io.ktor.client.engine.HttpClientEngine$install$1.invokeSuspend(HttpClientEngine.kt:154)
at io.ktor.client.engine.HttpClientEngine$install$1.invoke(HttpClientEngine.kt)
at io.ktor.client.engine.HttpClientEngine$install$1.invoke(HttpClientEngine.kt)
at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
at io.ktor.util.pipeline.DebugPipelineContext.proceed(DebugPipelineContext.kt:57)
at io.ktor.util.pipeline.DebugPipelineContext.execute$ktor_utils(DebugPipelineContext.kt:63)
at io.ktor.util.pipeline.Pipeline.execute(Pipeline.kt:92)
at io.ktor.client.plugins.HttpSend$DefaultSender.execute(HttpSend.kt:137)
at io.ktor.client.plugins.api.Send$Sender.proceed(CommonHooks.kt:47)
at io.ktor.client.plugins.HttpRedirectKt$HttpRedirect$2$1.invokeSuspend(HttpRedirect.kt:112)
at io.ktor.client.plugins.HttpRedirectKt$HttpRedirect$2$1.invoke(HttpRedirect.kt)
at io.ktor.client.plugins.HttpRedirectKt$HttpRedirect$2$1.invoke(HttpRedirect.kt)
at io.ktor.client.plugins.api.Send$install$1.invokeSuspend(CommonHooks.kt:52)
at io.ktor.client.plugins.api.Send$install$1.invoke(CommonHooks.kt)
at io.ktor.client.plugins.api.Send$install$1.invoke(CommonHooks.kt)
at io.ktor.client.plugins.HttpSend$InterceptedSender.execute(HttpSend.kt:115)
at io.ktor.client.plugins.api.Send$Sender.proceed(CommonHooks.kt:47)
at io.ktor.client.plugins.HttpCallValidatorKt$HttpCallValidator$2$2.invokeSuspend(HttpCallValidator.kt:128)
at io.ktor.client.plugins.HttpCallValidatorKt$HttpCallValidator$2$2.invoke(HttpCallValidator.kt)
at io.ktor.client.plugins.HttpCallValidatorKt$HttpCallValidator$2$2.invoke(HttpCallValidator.kt)
at io.ktor.client.plugins.api.Send$install$1.invokeSuspend(CommonHooks.kt:52)
at io.ktor.client.plugins.api.Send$install$1.invoke(CommonHooks.kt)
at io.ktor.client.plugins.api.Send$install$1.invoke(CommonHooks.kt)
at io.ktor.client.plugins.HttpSend$InterceptedSender.execute(HttpSend.kt:115)
at io.ktor.client.plugins.HttpSend$Plugin$install$1.invokeSuspend(HttpSend.kt:103)
at io.ktor.client.plugins.HttpSend$Plugin$install$1.invoke(HttpSend.kt)
at io.ktor.client.plugins.HttpSend$Plugin$install$1.invoke(HttpSend.kt)
at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
at io.ktor.util.pipeline.DebugPipelineContext.proceed(DebugPipelineContext.kt:57)
at io.ktor.client.plugins.RequestError$install$1.invokeSuspend(HttpCallValidator.kt:150)
at io.ktor.client.plugins.RequestError$install$1.invoke(HttpCallValidator.kt)
at io.ktor.client.plugins.RequestError$install$1.invoke(HttpCallValidator.kt)
at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
at io.ktor.util.pipeline.DebugPipelineContext.proceed(DebugPipelineContext.kt:57)
at io.ktor.client.plugins.SetupRequestContext$install$1.invokeSuspend$proceed(HttpRequestLifecycle.kt:42)
at io.ktor.client.plugins.SetupRequestContext$install$1.access$invokeSuspend$proceed(HttpRequestLifecycle.kt)
at io.ktor.client.plugins.SetupRequestContext$install$1$1.invoke(HttpRequestLifecycle.kt:42)
at io.ktor.client.plugins.SetupRequestContext$install$1$1.invoke(HttpRequestLifecycle.kt:42)
at io.ktor.client.plugins.HttpRequestLifecycleKt$HttpRequestLifecycle$1$1.invokeSuspend(HttpRequestLifecycle.kt:29)
at io.ktor.client.plugins.HttpRequestLifecycleKt$HttpRequestLifecycle$1$1.invoke(HttpRequestLifecycle.kt)
at io.ktor.client.plugins.HttpRequestLifecycleKt$HttpRequestLifecycle$1$1.invoke(HttpRequestLifecycle.kt)
at io.ktor.client.plugins.SetupRequestContext$install$1.invokeSuspend(HttpRequestLifecycle.kt:42)
at io.ktor.client.plugins.SetupRequestContext$install$1.invoke(HttpRequestLifecycle.kt)
at io.ktor.client.plugins.SetupRequestContext$install$1.invoke(HttpRequestLifecycle.kt)
at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
at io.ktor.util.pipeline.DebugPipelineContext.proceed(DebugPipelineContext.kt:57)
at io.ktor.util.pipeline.DebugPipelineContext.execute$ktor_utils(DebugPipelineContext.kt:63)
at io.ktor.util.pipeline.Pipeline.execute(Pipeline.kt:92)
at io.ktor.client.HttpClient.execute$ktor_client_core(HttpClient.kt:1415)
at io.ktor.client.statement.HttpStatement.fetchResponse(HttpStatement.kt:163)
at io.ktor.client.statement.HttpStatement.execute(HttpStatement.kt:77)
at KtorTest$withTimeoutOrNull$1$response$1.invokeSuspend(KtorTest.kt:46)
at KtorTest$withTimeoutOrNull$1$response$1.invoke(KtorTest.kt)
at KtorTest$withTimeoutOrNull$1$response$1.invoke(KtorTest.kt)
at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndspatched(Undispatched.kt:66)
at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturnIgnoreTimeout(Undispatched.kt:50)
at kotlinx.coroutines.TimeoutKt.setupTimeout(Timeout.kt:149)
at kotlinx.coroutines.TimeoutKt.withTimeoutOrNull(Timeout.kt:105)
at kotlinx.coroutines.TimeoutKt.withTimeoutOrNull-KLykuaI(Timeout.kt:137)
at KtorTest$withTimeoutOrNull$1.invokeSuspend(KtorTest.kt:17)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:124)
at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:89)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:586)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:820)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:717)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:704)
Note
- They run fine on Ktor
2.3.13 - When running the whole test class instead of individual tests, only 1 test fails
- Increasing the timeout can make the tests pass
Kotlin/JS: ktor-ktor-client-core.mjs is incompatible with Vite: toRaw naming conflict
When using ktor-client-core as a Kotlin/JS dependency in a Vite-based project (Nuxt 4 in this case), the app fails to initialize with the following error:
SyntaxError: Identifier 'toRaw' has already been declared
at ktor-ktor-client-core.mjs:6395:1
The root cause is that ktor-ktor-client-core.mjs declares a generator function named toRaw at the top-level of the module:
// ktor-ktor-client-core.mjs, line ~6393
function* toRaw(_this__u8e3s4, clientConfig, callContext, $completion) { ... }
toRaw is also a named export of @vue/reactivity (part of Vue 3 core). When Vite bundles both in the same scope for the browser, the duplicate declaration causes a hard SyntaxError that prevents the entire app from loading.
Steps to reproduce
-
Create a Nuxt 4 project
-
Build a Kotlin/JS browser library that includes
ktor-client-core(viajs { browser() }, then import the compiled output in yourpackage.jsonas a local dependency -
Instantiate the exported class anywhere in your Nuxt app:
import { ApiClient } from 'your-kotlin-library' // fine const client = new ApiClient('https://api.example.com') // 💥 crashes here -
Run the dev server
-
App fails to initialize immediately
Expected behavior
Internal Kotlin/JS identifiers should be scoped or mangled to avoid collisions with well-known JS library exports.
Actual behavior
The app crashes at startup with SyntaxError: Identifier 'toRaw' has already been declared, making Ktor client completely unusable in any Vite + Vue 3 environment.
Environment
ktor-client-core:3.4.2- Nuxt:
4.4.4 - Vue:
3.5.33 - Vite (via Nuxt)
- Browser target (Kotlin/JS)
Apache: body channel not cancelled when caller scope is cancelled
The body<ByteReadChannel>() channel returned by the Apache HTTP client engine is not cancelled when the caller's coroutine scope is cancelled.
Observed behavior: After cancelling the coroutine that called prepareGet(...).body<ByteReadChannel>(), the channel's closedCause remains null indefinitely.
Expected behavior: The body channel should be cancelled (with a CancellationException) when the caller's scope is cancelled.
Reproduction: A shared integration test testBodyChannelCancelledWhenCallerScopeIsCancelled in HttpStatementTest currently excludes the Apache engine because it fails there.
HttpClient: cancelling ByteReadChannel body does not propagate to engine
Problem
When a caller cancels a streaming ByteReadChannel response body:
client.prepareRequest(request).body<ByteReadChannel>().cancel()
the cancellation does not propagate back to the engine's active network transfer. The callContext children remain alive until the request timeout fires, instead of cleaning up immediately.
Root Cause
DefaultTransform wraps the raw engine body in a writer { body.copyTo(channel) } coroutine and returns channel to the caller. When the caller calls channel.cancel(), the write side of that channel closes — but copyTo is suspended reading from body (the raw engine channel), not writing. The cancellation never propagates backward to body, so copyTo stays blocked.
This leaves responseJobHolder — a Job child of callContext that completes only when the writer finishes — stuck in Active state, keeping callContext in Completing and all engine children alive (CIO body reader coroutine, curl handle, Darwin reader, etc.) until the request timeout fires.
Affected Engines
All engines
Content-Disposition additional parameters should be inside quotes
This issue was imported from GitHub issue: https://github.com/ktorio/ktor/issues/1691
Ktor Version and Engine Used (client or server and name)
1.3.1, client
Describe the bug
Content-Disposition additional parameters should be inside quotes in multi-part Request body.
To Reproduce
Steps to reproduce the behavior:
- Multi-part request
import io.ktor.client.request.forms.append
<...>
httpClient.post<String> {
body = MultiPartFormDataContent(formData {
append(
"file",
"file.txt",
ContentType.parse("text/plain")
) {
writeText("content")
}
})
}
<...>
- Observe part data in Request body:
Content-Disposition: form-data; name=file; filename=file.txt
Expected behavior
Header matching spec:
The first parameter in the HTTP context is always form-data. Additional parameters are case-insensitive and have arguments that use quoted-string syntax after the '=' sign. Multiple parameters are separated by a semi-colon (';').
Content-Disposition: form-data; name="file"; filename="file.txt"
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
Curl: body channel not cancelled when caller scope is cancelled
The body<ByteReadChannel>() channel returned by the Curl HTTP client engine is not cancelled when the caller's coroutine scope is cancelled.
Root cause: bodyChannel is attached to request.executionContext (a SupervisorJob) instead of callContext. callContext is a child of request.executionContext, so cancellation from the caller side (via attachToUserJob) only reaches callContext — it does NOT propagate up to the SupervisorJob. Attaching to the wrong job leaves the channel open when the caller's scope is cancelled.
Fix: Attach bodyChannel to callContext instead of request.executionContext in CurlHttpResponseBody.
Curl: CancelWebSocket task may cancel a new HTTP request due to easy handle pointer reuse
Problem
testHttpRequestAfterWebSocketClose fails for the Curl engine on Windows:
kotlin.AssertionError: Test failed for engine 'native:Curl' with:
kotlin.coroutines.cancellation.CancellationException: WebSocket session closed
at io.ktor.client.engine.curl.CurlProcessor.$drainTaskQueueCOROUTINE$4.invokeSuspend#internal
Root cause
Introduced by #5469 (commit 5643569708b7a483e52bac1166b86603c99ad83f), which fixed a WebSocket handle leak by enqueuing a CancelWebSocket(easyHandle) task whenever a WebSocket session closes.
The fix correctly guards against the cancelled handle already being gone from activeHandles (?: continue), but misses the ABA pointer-reuse case:
- WebSocket TCP connection closes naturally →
curl_multi_info_readfires →handleCompletedremoves the handle fromactiveHandlesand callscurl_easy_cleanup, freeing the pointer. - A new HTTP request arrives →
curl_easy_init()reuses the same pointer address → added toactiveHandles. CancelWebSocket(oldHandle)is dequeued →activeHandles.remove(oldHandle)finds the new HTTP request at that address → cancels it withCancellationException("WebSocket session closed").
The bug only manifests for WebSockets because CurlWebSocketSession.close() always enqueues a cancellation (even on clean close), whereas regular HTTP request cancellation only fires on error.
Fix
Carry CurlWebSocketResponseBody in CancelWebSocket instead of the raw EasyHandle. In CurlMultiApiHandler.cancelWebSocket(), verify identity before acting:
fun cancelWebSocket(websocket: CurlWebSocketResponseBody, cause: Throwable) {
val easyHandle = websocket.easyHandle
val handler = activeHandles[easyHandle] ?: return
if (handler.responseWrapper.get() !== websocket) return // ABA check
activeHandles.remove(easyHandle)
processCancelledEasyHandle(easyHandle, cause)
handler.responseCompletable.completeExceptionally(cause)
handler.dispose()
}
If curl_easy_init reused the pointer for a new request, handler.responseWrapper.get() returns the new request's response body, which is not the same object as websocket, so we return early and the new request is left untouched.
Curl: WebSocket bearer token refresh fails due to stale native handle reuse
When a WebSocket connection is attempted with an invalid bearer token, the server returns 401. The Curl engine processes the 401, cleans up the curl easy handle (freeing its native memory), but still creates a CurlWebSocketSession wrapping the now-dead handle.
When the Auth plugin cancels the call context to retry with a refreshed token, CurlWebSocketSession.close() enqueues a CancelWebSocket task with the freed native pointer. If curl_easy_init() reuses that same address for the retry request (ABA problem), the CancelWebSocket task matches the new valid handle — cancelling the retry with CancellationException("WebSocket session closed").
Fix
In CurlClientEngine.execute(), only create CurlWebSocketSession when the response status is 101 Switching Protocols. For any other status, return ByteReadChannel.Empty — the easy handle is already cleaned up at that point.
Reproduction
testAuthenticationWithValidRefreshToken in WebSocketTest with the Curl engine. Fails consistently (not just flaky) on macOS.
OkHttp: Websockets pinging doesn't work
Scenario 1
val okHttpEngine = OkHttp.create()
val client = HttpClient(okHttpEngine) {
install(WebSockets) {
pingInterval = 20_000
}
}
client.webSocket("ws://localhost:8081") {
send(Frame.Text("Hello"))
incoming.consumeAsFlow().collect {
println("Frame Received: $it")
}
}
It's expected that pinging will work. However, it doesn't. It doesn't ping and can lead to silent failures.
Scenario 2
val okHttpEngine = OkHttp.create()
val client = HttpClient(okHttpEngine) {
install(WebSockets)
}
client.webSocket("ws://localhost:8081") {
pingIntervalMillis = 20_000 // WebSocketException("OkHttp doesn't support dynamic ping interval. You could switch it in the engine configuration." )
}
An exception is thrown.
It's understandable that it is an OkHttp limitation, and It's easy to see in the source code why this happens in Ktor.
However, I think both scenarios have similar set-ups and should have to have similar outcomes. At least a runtime warning when configuring the WebSocket in Scenario 1. But instead it can fail silently without any kind of feedback.
Jetty, Java: Custom Host header doesn't override the default value
Jetty client engine doesn't allow to correctly overwrite the default Host header, and sends a duplicate one. After sending a request to nginx:
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.*
import io.ktor.client.engine.jetty.*
import io.ktor.client.request.*
class KtorJettyHeaders : FreeSpec() {
init {
"setting host header should work" {
val client = HttpClient(Jetty)
val result = client.get("http://localhost:2060/test.json") {
header("Host", "test")
}
result.status.value shouldBe 200
}
}
}
I get a "400 Bad Request" response, with an explanation in nginx log:
[info] 10#10: *94 client sent duplicate host header: "host: test", previous value: "host: localhost:2060" while reading client request headers, client: 172.19.0.1, server: _, host: "localhost:2060"
Java engine also doesn't work correctly, it ignores the Host header.
Setting custom Host header works correctly with CIO, Apache5 and OkHttp.
Core
Make ktor-network compatible with ES modules for nodejs
https://github.com/ktorio/ktor/pull/4411 introduces support for TCP/Unix sockets in ktor-network for nodejs for js/wasm-js targets. (KTOR-6004)
The current approach is to use eval('require')('node:net') to access nodejs APIs and it's not compatible with ES modules (more info in KTOR-6158).
This "hack" is used there because after implementing ktor-client-cio for js/wasm-js I was failed to run ktor-client-tests module tests in browser, as with direct dependency on net via JsModule annotation it's not possible to use it from browser, and I see the error like this coming from webpack:
Module not found: Error: Can't resolve 'net' in '.../ktorio/ktor/build/js/packages/ktor-ktor-client-ktor-client-tests-test/kotlin'
Uncaught Error: Cannot find module 'net'
Even if it will be not really used in ktor-client-tests when running in browser, the code is there and so webpack will complain.
This should be revisited after CIO client and server for js and wasm-js will be merged and fixed in some way, so that all tests in ktor-client-tests are run for nodejs with CIO and ES modules are supported
Docs
Documentation for DNS configuration for the Apache5 client
Another update for DNS configuration, but for the Apache5 client.
This introduces the dnsResolver top-level property for configuring the engine to use different DNS servers.
PR: https://github.com/ktorio/ktor/pull/5571
Example:
HttpClient(Apache5) {
engine {
dnsResolver = SystemDefaultDnsResolver.INSTANCE
}
}
Documentation for DNS configuration for OkHttp client engine
We had an external contribution to introduce DNS configuration to the OkHttp client engine. Link to PR https://github.com/ktorio/ktor/pull/5570/
The new configuration looks like this:
HttpClient(OkHttp) {
engine {
dns = Dns { hostname -> listOf(InetAddress.getByName("127.0.0.1")) }
}
}
This makes it easier to configure the domain name server where URLs are resolved to addresses.
Documentation for Add an option to not resend the session cookie if the session data wasn't changed.
Description
Introduces an option in the sessions plugin that makes it so the server only sends the "Set-Cookie" when changes are made to the cookie storage.
The default behaviour remains the same, but can be modified with the sendOnlyIfModified flag is set in the plugin configuration.
Code example
install(Sessions) {
cookie<MySession>("SESSION") {
sendOnlyIfModified = true
}
}
Documentation for Provide parameter validation convenience functions
This feature introduces convenience functions like ApplicationCall.requireXxx functions to automatically throw bad request exception on missing parameters.
Examples:
ApplicationCall.requireQueryParameter(name)
ApplicationCall.requireHeader(name)
ApplicationCall.requireCookie(name, encoding)
RoutingCall.requirePathParameter(name)
Github PR: https://github.com/ktorio/ktor/pull/5522
Github Issue: https://github.com/ktorio/ktor/issues/5397
Documentation for OpenAPI: Support prefixItems in JsonSchema for tuple type definitions
This feature introduces a prefixItems property to the JSON schema support. This is a list of schema references that apply to the respective list elements in a list schema type.
I'm noticing now we don't have a full list of the JSON schema properties in our documentation, so maybe it's not required. Maybe it would be beneficial to provide a link to the KDoc from https://ktor.io/docs/openapi-spec-generation.html#schema-inference to the JsonSchema class under a new section specifying how you can construct your own schema when the automatic inference is insufficient.
Change the year format used in "Logging"
Yuri says about Logging (version 3.1.0):
Change
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
to
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>From my understanding, the key difference is that:
yyyy represents the calendar year, which is commonly used.
YYYY represents the ISO week-based year, which may differ from the calendar year in the first or last few days of the year.
Documentation for Support getAs from the root ApplicationConfig
Please provide the following details so the technical writing team can begin work on this issue.
Description
Describe what this feature or change does and what problem it solves.
Code example
Provide a code example or link to a working sample (if applicable).
You can also link to a repo, PR, or snippet.
Migration guide
Does this change require a migration guide for existing users?
- [ ] Yes
- [ ] No
Related Links (Optional)
Add any relevant references:
- Blog posts
- Design or product docs (e.g., Quip, KEEP)
- PRs or related issues (other than the parent issue)
Once the description is complete, please re-assign this issue to a Technical Writer.
Infrastructure
JS: Make ES2015 the default target for tests
Kotlin is moving toward raising the default JS target (KT-70477). We should at least change the target to ES2015 in tests to ensure Ktor is compatible with this standard.
Upgrade to Kotlin 2.3.21
- [x] Update dependency
- [x] Update "Kotlin Version" badge
Network
Flaky UnixSockets on Windows: WSAEOPNOTSUPP from bind()
Problem
io.ktor.network.sockets.tests.UnixSocketTest.testEchoOverUnixSockets fails intermittently on Windows with a ~3.8% failure rate on identical Windows Server 2022 CI agents.
Error:
io.ktor.utils.io.errors.PosixException.PosixErrnoException: POSIX error 10045: Unknown error (10045)
at io.ktor.network.sockets.tcpBind$$inlined$buildOrCloseSocket$1.invoke#internal
at io.ktor.network.util.NativeUnixSocketAddress.NativeUnixSocketAddress$nativeAddress$1.invoke#internal
at io.ktor.network.util#pack_sockaddr_un
at io.ktor.network.util.NativeUnixSocketAddress#nativeAddress
at io.ktor.network.sockets#tcpBind#suspend
Error 10045 is WSAEOPNOTSUPP, thrown by ktor_bind() when binding a Unix domain socket.
Observations
- All CI runners use the same pool of identical Windows Server 2022 agents — heterogeneous environments ruled out.
- Successful runs complete in ~10ms; failing runs take ~40–50ms, suggesting a ~30ms delay before the bind fails.
- Socket path length is 79 chars (well within the 108-byte
sun_pathlimit). - On successful runs:
ioctlsocket(FIONBIO)returns 0,WSAGetLastError()is 0 after it — no stale error state.
Server
Add an option to not resend the session cookie if the session data wasn't changed.
This issue was imported from GitHub issue: https://github.com/ktorio/ktor/issues/1654
Subsystem
Server
Is your feature request related to a problem? Please describe.
Ktor is awesome and its session data in cookies are also awesome! But I noticed that Ktor always resends the Set-Cookie header even if the session data wasn't changed at all. This increases bandwidth costs (not really significant) and breaks asset caching via Cloudflare. (Cloudflare does not cache the asset because it thinks it is a dynamic asset due to Ktor sending the Set-Cookie header back)
I only tested with the SessionTransportTransformerMessageAuthentication transformer, but I think this affects any other transformer too.
Describe the solution you'd like
It would be nice if there was an option to not force resending the cookies if the session data wasn't changed. (maybe by checking if (oldData != newData) { resend } else { don't }?)
Or maybe by adding an way to intercept the session cookie creation? (While you can add a intercept phase before the session is written to the cookie, you can't remove the attribute because the SessionKey is private)
Or maybe by adding content filters, not sending the Set-Cookie data for specific content types? That way at least caching proxies wouldn't break. :)
Motivation to include to ktor
Trying to clone Ktor's session feature is hard because you can't just copy the Sessions class, do your changes and be done with it, also because it is kinda unnecessary to resend the Set-Cookie header if nothing was changed.
Also I'm not sure if that could've been considered a "bug", not a "feature".
tl;dr: Every time a client sends a Set-Cookie header, Ktor sends a Set-Cookie back even if the session data is exactly the same. This uses more bandwidth (because it is useless data) and breaks some asset caching proxies. (Example: Static files served via static are not cached because Ktor sends the Set-Cookie header back)
Provide parameter validation convenience functions
See issue https://github.com/ktorio/ktor/issues/5397
The idea here is to provide simpler ways to perform:
call.parameters["my-param"] ?: throw BadRequestException("Missing 'my-param'")
OpenAPI: Support prefixItems in JsonSchema for tuple type definitions
It would be really nice for JsonSchema to support `prefixItems`.
That appears to be the canonical way in JSON Schema to encode tuples i.e. fixed length arrays with heterogeneous fixed-position types.
CIOMultipartDataBase: Call thread is blocked when releasing file parts
I have following snippet:
fun Application.configureRouting() {
routing {
post("/upload") {
withMultipartDataDispose(call.receiveMultipart()) { parts ->
(parts.readPart() as PartData.FileItem).provider().toByteArray()
call.respond(HttpStatusCode.OK)
}
}
}
}
private suspend fun withMultipartDataDispose(multipartData: MultiPartData, block: suspend (MultiPartData) -> Unit) {
try {
return block(multipartData)
} finally {
try {
multipartData.forEachPart {
println("Disposing part: ${it.name}")
it.dispose()
println("Disposed part: ${it.name}")
}
} catch (e: Exception) {
throw e
}
}
}
I know this isn’t the ideal way to work with multipart, but it’s a rather interesting case for me. If I send several files, the server eventually hangs and stops accepting new requests without any errors. If I take a thread dump, I can see that many coroutines are waiting for more bytes. Here are the server logs of requests being processed one by one (not in parallel):
2025-12-22 14:24:16.140 [main] INFO Application - Application started in 0.221 seconds.
2025-12-22 14:24:16.230 [main] INFO Application - Responding at http://0.0.0.0:8080
Disposing part: file2
Disposing part: file2
Disposing part: file2
Disposing part: file2
Disposing part: file2
After that no more new requests are processed. There's also thread dump:
threads_report.txt
This issue could be resolved by moving this line out of the lambda passed to 'withMultipartDataDispose':
call.respond(HttpStatusCode.OK)
I'm curious why this happens, why can't I discard bytes if I give the client a response earlier? If I understand correctly, the client sends all the bytes to the socket, they are available in the OS, so why can't I discard them?
Netty: The request handler runs on worker event loop instead of call event loop since 3.4.3
It seems that a change in 3.4.3 caused the Netty server to ignore the call group and execute request handlers on the worker group. I added a (note: mostly generated) reproduction in this draft PR: https://github.com/ktorio/ktor/pull/5560/changes
It's also easy to reproduce by logging thread names. I initially reported this in the comments to KTOR-9531, but a maintainer suggested creating a separate issue since the two behaviors aren't proven to have the same cause. (I'd be surprised if a patch release introduced two independent issues of this severity though)
The issue appears to have been introduced in https://github.com/ktorio/ktor/pull/5421 which was made to fix KTOR-9343.
Route.contentType should support multiple ContentType
The accept route handler supports multiple ContentTypes but the contentType handler does not:
fun Route.foo() {
accept(ContentType.Application.Json, ContentType.Application.Xml) {
contentType(ContentType.Application.Json, ContentType.Application.Xml) {
post {
}
}
}
Module: ktor-server-core
MicrometerMetrics: "MeterFilters configured after a Meter has been registered" warning when a metric is registered before installing the plugin
In Micrometer support for ktor there is MeterFilter registration
which produces following warning in log:
A MeterFilter is being configured after a Meter has been registered to this registry. All MeterFilters should be configured before any Meters are registered. If that is not possible or you have a use case where it should be allowed, let the Micrometer maintainers know at https://github.com/micrometer-metrics/micrometer/issues/4920.
Which happens if metrics were registered before setting up Ktor, which happens in our case where Ktor is not the only thing that produces metrics and it is hard to create Ktor before all other classes that also registers metrics.
CallLogging: plugin usage in testApplication breaks console standard output
The console output completely stops after using the client in testApplication, which utilizes the CallLogging plugin.
When running the test via Run Configurations (Ctrl+Shift+F10) and selecting test3 from the results, you can see that messages stop appearing, and in test4, they are completely absent.
{width=722px}
However, in Test Results, the entire output is displayed correctly.
{width=70%}
At this point, I was sure that the issue was most likely somewhere in IDEA, but running the tests in the terminal via Gradle showed the exact opposite: only the messages that do not appear in test3 and test4 were displayed.
PS C:\test> ./gradlew :clean :test --tests "CallLoggingPluginTests"
01:11:25.113 INFO DefaultDispatcher-worker-3 @request#12 i.ktor.test 200 OK: GET - / in 16ms
t3-3
t4-1
At this point, I no longer fully understand the nature of the problem.
class CallLoggingPluginTests {
@Test
fun test1() {
println("t1-1")
}
@Test
fun test2() {
println("t2-1")
testApplication {
application {
routing {
get("/") {
println("t2-2")
call.respond(HttpStatusCode.OK)
}
}
}
client.get("/")
println("t2-3")
}
}
@Test
fun test3() {
println("t3-1")
testApplication {
application {
install(CallLogging)
routing {
get("/") {
println("t3-2")
call.respond(HttpStatusCode.OK)
}
}
}
client.get("/")
println("t3-3")
}
}
@Test
fun test4() {
println("t4-1")
}
}
Deprecation notice for io.ktor.server.auth.Principal does not explain what to use instead
@Deprecated("This interface can be safely removed")
public interface PrincipalBut should I replace it with something else or no?
Jetty Jakarta: Provide an easy way to disable SNI hostname validation
Ktor 3.0.0-rc-1
It is often important to access a server via a different hostname or IP address. The most common use-case is deploying a new version of an app and then checking it is up and running before pointing the production hostname to it. With the new jetty-jakarta server, validation is now stricter than before out of the box. It should be possible to switch to a more lenient validation if desired or at the very least get access to Jetty's SecureRequestCustomizer to be able to disable that check ourselves.
Support getAs from the root ApplicationConfig
Currently it is not possible to deserialise the root of ApplicationConfig into a data class. This is quite useful when taking full control of the ApplicationConfig using the new deserialisation support, especially with embeddedServer. Currently it nesting in a single property.
app:
port: 8080
host: "0.0.0.0"
security:
clientId: $CLIENT_ID
clientSecret: $CLIENT_SECRET
@Serializable data class App(val port: Int, val host: String)
@Serializable data class Security(val clientId: String, val clientSecret: String)
val app = ApplicationConfig("application.yaml").property("app").getAs<App>()
val security = ApplicationConfig("application.yaml").property("security").getAs<Security>()
With root getAs support this can be simplified to just the root config.
app:
port: 8080
host: "0.0.0.0"
security:
clientId: $CLIENT_ID
clientSecret: $CLIENT_SECRET
@Serializable data class App(val port: Int, val host: String)
@Serializable data class Security(val clientId: String, val clientSecret: String)
@Serializable data class Config(val app: App, val security: Security)
val config = ApplicationConfig("application.yaml").getAs<Config>()
Autoreloading: default watch patterns don't match anything when project path contain spaces
To reproduce the problem start the server from the attached sample project.
As a result, the following unexpected line is printed to the log:
No ktor.deployment.watch patterns match classpath entries, automatic reload is not active
The problem is that the watch URLs with the spaces in the path segments are encoded as %20, which break the matching with the default watch patterns.
Custom SSE heartbeat function
As a developer, I want to be able to use a custom heartbeat function for my SSE endpoint, so that I can include some useful information.
See issue github.com/ktorio/ktor/issues/5518
Dependency injection: read annotations in function references
As a Ktor developer, I want to be able to use annotations on function parameters when referencing them from code.
fun Application.module() {
dependencies {
provide(::initDatabase)
}
}
fun initDatabase(@Property("db.connectionUrl") connectionUrl): DataSource {
TODO()
}
RawSourceChannel returns false positive on awaitContent
Netty engine still print annoying exceptions
This issue was imported from GitHub issue: https://github.com/ktorio/ktor/issues/1030
Ktor Version
1.1.3
Ktor Engine Used(client or server and name)
Netty - Firefox
JVM Version, Operating System and Relevant Context
Windows10 JDK8
Feedback
When I use Firefox to access web site build by ktor, exceptions are often printed in server logs as following:
2019-03-20 14:35:59.083 [nettyWorkerPool-3-2] DEBUG Application - I/O operation failed
java.io.IOException: 你的主机中的软件中止了一个已建立的连接。
at sun.nio.ch.SocketDispatcher.read0(Native Method)
at sun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:43)
at sun.nio.ch.IOUtil.readIntoNativeBuffer(IOUtil.java:223)
at sun.nio.ch.IOUtil.read(IOUtil.java:192)
at sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:380)
at io.netty.buffer.PooledUnsafeDirectByteBuf.setBytes(PooledUnsafeDirectByteBuf.java:288)
at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1108)
at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:345)
at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:148)
at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:645)
at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:580)
at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:497)
at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:459)
at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:884)
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.lang.Thread.run(Thread.java:748)
Netty call hang when channel becomes inactive before response is sent
The call finishes when channel becomes inactive before response is sent test in NettySpecificTest started failing after merging 3.4.3 into main.
I'll ignore the failure for now to unblock further merges.
Sessions: Add a way to create a user session shared for all user devices or look up sessions of the same user
The use case: Invalidate all the other user sessions when they create a new session to allow only one active session across all user devices.
The user logs in to one device (mobile or web), and a new session is created on the server. Then, the same user logs in from another device (mobile or web) and creates a new session. The session from the old device must be invalidated, and only the session from the new device should be active.
This problem can be solved by allowing the user to create a session ID based on the incoming data (a cookie or a header). Currently, the session ID generator has the following signature:
sessionIdProvider: () -> String
Also, an overload for the
CurrentSession.clearmethod with the session ID parameter would be helpful.
call.respond performance regression caused by transitive kotlin-reflect:2.3.0
problem:
When I use load testing a see regress when my app send response with dto with generic and generic is null.
data class BaseDto<out T>(val data: T?)
respond(BaseDto(null))
Ktor 3.3.2 working fine, but Ktor 3.4.1 working bad.
if add <Type> , for example response<Int>(null), Ktor 3.4.1 working good
I used JFR and I saw lock in Serializer->SerializerCache.findParametrizedCachedSerializer->ConcurentHashMap.putIfAbsent->ConcurrentHashMap.putVal()
Maybe problem with serialization null with type Nothing?
example:
https://github.com/DmitryPanteleev/ktor-sample-regress-example.git
Netty response hangs after connection lost
When using the Netty server engine, when the socket connection is lost, the Ktor request is not canceled. This could lead to memory leaks.
Make DynamicProviderConfig.authenticateFunction suspend
Api symbol: io.ktor.server.auth.DynamicProviderConfig:
We need suspend authenticateFunction to create custom provider with coroutines:
install(Authentication) {
provider("jwt-token-b") {
authenticate { context ->
val call = context.call
val jwtToken = call.request.headers["X-Token"]?.let { jwtTokenB ->
JWT.from(jwtTokenB)
}
val userId = if (jwtToken != null && runBlocking {
jwtToken.verify {
es512 { der(serverPrivateKey, curve) }
notBefore()
issuer(ISSUER)
expiresAt()
audience("gravit-api")
}
}) {
} else {
null
}
if (userId != null) {
context.principal(UserIdPrincipal(userId))
} else {
context.challenge(
"jwt-token-b",
AuthenticationFailedCause.InvalidCredentials
) { challenge, call ->
call.respondEither(
either {
raise(EitherError.InvalidTokenError("Token is invalid or missing"))
})
challenge.complete()
}
}
}
}
}
Plugin onCallReceive/transformBody is not called for receive<ByteArray>()
Hi folks,
I recently fiddled around with a plugin to transform request bytes in a ktor application.
I setup a minimum example here:
package org.example
import io.ktor.client.HttpClient
import io.ktor.client.engine.apache.Apache
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.createApplicationPlugin
import io.ktor.server.application.install
import io.ktor.server.engine.embeddedServer
import io.ktor.server.jetty.jakarta.Jetty
import io.ktor.server.request.receive
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import kotlinx.coroutines.runBlocking
fun Application.module() {
install(createApplicationPlugin(name = "SomeApplicationPlugin") {
onCallReceive { call ->
transformBody {
println("onCallReceive")
it
}
}
})
configureRouting()
}
fun Application.configureRouting() {
routing {
post("/") {
call.receive<ByteArray>()
}
}
}
fun main(args: Array<String>) {
runBlocking {
embeddedServer(Jetty, port = 8080, host = "127.0.0.1", module = Application::module).start()
val client = HttpClient(Apache)
client.post("http://127.0.0.1:8080") {
contentType(ContentType.Text.Plain)
setBody("This is a test")
}
}
}
Why is call.receive<ByteArray>() not causing the plugin to call transformBody()? However, when I change it to call.receive<String>(), onCallReceive is printed to stdout. This at least looks to me like unexpected, if not buggy behavior. I would like to understand why that happens. And I think it would be valuable to add that information to the docs. I was not able to find any hints in that regard.
Thanks, and keep up with the great work!
-David
Websockets: webSocket builder function should return a Route to be describable
Just like public fun Route.get/post/put/delete etc., the websocket method should also return a Route.
C.f.:
public fun Route.webSocket(
path: String,
protocol: String? = null,
handler: suspend DefaultWebSocketServerSession.() -> Unit
) {
webSocketRaw(path, protocol, negotiateExtensions = true) {
proceedWebSocket(handler)
}
}
public fun Route.webSocketRaw(
path: String,
protocol: String? = null,
negotiateExtensions: Boolean = false,
handler: suspend WebSocketServerSession.() -> Unit
) {
plugin(WebSockets) // early require
route(path, HttpMethod.Get) {
webSocketRaw(protocol, negotiateExtensions, handler)
}
}
vs
public fun Route.get(path: String, body: RoutingHandler): Route {
return route(path, HttpMethod.Get) { handle(body) }
}
Currently, it's impossible (or at least not straightforward) to use describe from io.ktor.server.routing.openapi since the created route object is not returned.
// This is OK
get("/foo/") { /* ... */ } .describe {
summary = "Get Foo"
}
// Unresolved reference - webSocket returns a Unit
webSocket("/bar/") { /* ... */ } .describe {
summary = "WebSocker Bar"
}
Shared
The JacksonConverter.streamRequestBody property name is confusing
The streamRequestBody parameter/property of io.ktor.serialization.jackson.JacksonConverter (doc) actually configures whether the response is streamed.
To avoid confusion it should be renamed and the description should be fixed in both the constructor and the extension property on Configuration.
(This is also currently relevant, since projects using Jackson content negotiation + compression will want to find and disable this option in order to get rid of the warning introduced in KTOR-5977.)
Add known TDM headers to the HttpHeaders object
Feature
Currently, the HttpHeaders class does not have entries for TDM headers.
It would be nice if they could be added:
public val TDMReservation: String = "TDM-Reservation"
public val TDMPolicy: String = "TDM-Policy"
Although in the spec, the headers are lowercase, I chose to put TDM in full uppercase, as that is how its otherwise referred to in the document, and for the second word to be capitalized to match with other http headers.
Example Usage/Usecase:
get("/my/route") {
call.respondText("Hello World")
call.response.header(HttpHeaders.TdmReservation, 1)
call.response.header(HttpHeaders.TdmPolicy, "https://provider.com/policies/policy.json")
}
a GET request to /my/route might then produce a response similar to the following:
HTTP/1.1 200 OK
TDM-Reservation: 1
TDM-Policy: https://provider.com/policies/policy.json
Content-Type: text/plain;charset=utf-8
Hello World
Jackson, with request body streaming on, exhausts Dispatchers.IO
Both slow clients (communicating with ktor server) and slow servers (responding to ktor client) can lead to quick exhaustion of the Dispatchers.IO threadpool. In highly concurrent scenarios the default limits are easy to reach as well and very hard to work around (essentially requires writing your own JsonConverter).
This is because Ktor always moves stream-based content (de-)serialization to Dispatchers.IO without this being configurable. This impacts both sending (OutputStreamContent) and receiving (e.g. JacksonConverter.deserialize) in both ktor-server and ktor-client. Increasing D.IO somewhat alleviates the problem, but the threadpool is still easily exhaustable.
Imagine a scenario where ktor communicates with an upstream service which is getting overloaded, goes down and all requests time out. With default OS timeouts being quite long, if 64 requests were in progress, this will now hog the IO dispatcher until the TCP stream timeout actually hits and the stream read errors.
Imo the dispatcher which is used for this operation should at least use IO.limitedParallelism() to avoid impacting other uses within the app relying on D.IO. Ideally, the dispatcher would be configurable (to move it to virtual threads) – or the IO happening on the stream actually needs to be suspend (would need a reentrant JSON parser? Or one that supports suspend).
As a funny aside – trying to reproduce this in tests actually causes a complete deadlock since both client and server will use the same D.IO pool but can never get enough resources to actually finish a request. Repro is attached. Reducing the requests made or increasing the D.IO pool (from 64 default) will make the tests pass.
ZSTD decoder fails if the compressed frame is larger than 4096 bytes
Based on my reading of the code, the ZSTD decoder will fail if an individual frame is larger than 4096 bytes.
This is because it requires the entire frame to be able to be held in the inputBuf byte buffer: https://github.com/ktorio/ktor/blob/9e2a69131f4762f049bd01802a63ffa9308638b8/ktor-shared/ktor-encoding-zstd/jvm/src/io/ktor/encoding/zstd/Zstd.jvm.kt#L88-L89
and the size of that byte buffer is a maximum of 4096 bytes due to the use of KtorDefaultPool which is a pool of byte buffers that are all 4096 bytes long:
there exists a test which attempts to account for this, but it fails to do so: https://github.com/ktorio/ktor/blob/9e2a69131f4762f049bd01802a63ffa9308638b8/ktor-shared/ktor-encoding-zstd/jvm/test/io/ktor/encoding/zstd/ZstdTest.kt#L33-L45
from some empirical testing, the compressed sizes of the frames in that test are actually only 21 bytes long (for the first four, the final frame is 17 bytes compressed)
Test Infrastructure
MockEngine, HttpTimeout: the virtual clock of kotlinx coroutines isn't respected
I want to test my clients timeout behavior. To have a deterministic test which also runs as fast as possible I decided to use the kotlinx.coroutines test dispatcher which comes with a virtual clock. It seems that the ktor's HttpTimeoutPlugin does not respect the virtual clock of kotlinx coroutines
To reproduce, run the following test:
runTest {
val mutex = Mutex(locked = true)
val mockEngine = MockEngine {
mutex.withLock {
respond("OK")
}
}
val client = HttpClient(mockEngine) {
install(HttpTimeout) {
requestTimeoutMillis = 100
}
}
launch {
delay(200)
mutex.unlock()
}
// Assertion error since no exception is thrown
// Test is green when no coroutine test scope is used
assertThrows<HttpRequestTimeoutException> {
client.get("/")
}
}
As a result, unexpectedly the HttpRequestTimeoutException isn't thrown.
Other
Netty server intermittently drops requests after upgrading to 3.4.3
Since upgrading to Ktor 3.4.3 my server has begun to intermittently drop requests. The request pipeline doesn't seem to start in these occurrences since none of my code executes, including custom application-level plugins. There's also no logs from Netty or Ktor - it's like the request never existed. I have downgraded to Ktor 3.4.2 and confirmed the issue no longer occurs.
Though I'm not sure if it's related, I noticed the following debug-level message from Netty is now logged on every (non-dropped) call:
2026-04-27 16:56:33.586 [i.n.c.DefaultChannelPipeline] DEBUG Discarded inbound message io.ktor.server.netty.http1.NettyHttp1ApplicationCall@1081057f that reached at the tail of the pipeline. Please check your pipeline configuration.
2026-04-27 16:56:33.586 [i.n.c.DefaultChannelPipeline] DEBUG Discarded message pipeline : [ssl, codec, io.opentelemetry.javaagent.shaded.instrumentation.netty.v4_1.internal.server.HttpServerTracingHandler, continue, timeout, http1, RequestBodyHandler#0, DefaultChannelPipeline$TailContext#0]. Channel : [id: 0x5add81f7, L:/10.0.20.11:443 - R:/10.0.20.133:45142].
This message isn't logged in 3.4.2.
Deprecate HttpHeaders.AcceptCharset
As per RFC 9110 #12.5.2:
Note: Accept-Charset is deprecated because UTF-8 has become nearly ubiquitous and sending a detailed list of user-preferred charsets wastes bandwidth, increases latency, and makes passive fingerprinting far too easy (Section 17.13). Most general-purpose user agents do not send Accept-Charset unless specifically configured to do so.
We should add a deprecation note to this constant.
Update Digest authentication implementation according to RFC 7616
It seems that the current implementation follows obsolete RFC 2617.
We should update it to follow the updated specification — RFC 7616
Expected changes
(the list might not be comprehensive)
Server:
- add sending of required
qopparameter - change the default hashing algorithm to
SHA-256(while keepingMD5as a fallback value)- (nice to have) provide constants or enum for supported algorithms
- (nice to have) add sending of parameters
encodinganduserhash
Client and Server:
- update digest calculation implementation
- add support for
-sessalgorithms - add support of
qop=auth-int - add support for Username Hashing
- add support for