Changelog 3.5 version
3.5.2
released 4th August 2026
Client
SSE client (CIO engine) sends spurious `Content-Length: 0` on body-less GET (RFC 9110 § 8.6 violation; rejected by AWS ELB)
Summary
HttpClient(CIO).sseSession(url) / HttpClient(CIO).sse(url) emit a Content-Length: 0 request header on the outgoing GET, even though there is no request body and GET semantics do not anticipate one. This violates RFC 9110 § 8.6 ("A user agent SHOULD NOT send a Content-Length header field when the request message does not contain content and the method semantics do not anticipate such content"), and causes strict reverse proxies — notably AWS ELB — to reject the request with 400 Bad Request (treating it as a request-smuggling smell per RFC 9110 § 9.3.1).
curl, java.net.http.HttpClient via HttpRequest.Builder.GET(), browsers, and python-requests all omit the header in the same situation. Ktor CIO is the outlier.
Scope: which engine is broken
The defect is observable on the wire only with the CIO engine.
| Engine | Wire behavior on body-less SSE GET | Source-level state |
|---|---|---|
| CIO | Writes Content-Length: 0 to the wire |
Buggy: hasContent = body !is OutgoingContent.NoContent doesn't unwrap ContentWrapper, so the SSE plugin's SSEClientContent makes hasContent = true and the explicit headerLine(ContentLength, …) fires. |
| Java | Does not write Content-Length to the wire on JDK 21 (HTTP/1.1 and HTTP/2) |
Source path is correct in practice: Content-Length is in DISALLOWED_HEADERS so it's stripped, and convertToHttpRequestBody recurses through ContentWrapper to NoContent → BodyPublishers.noBody(). The wire result then depends on JDK behavior for noBody(), which is currently to omit the header. |
So the production fix lives in CIO. The Java engine is incidentally correct because Ktor delegates the body-shape decision to the JDK and the JDK behaves correctly. A defensive change on the Java engine to also explicitly recognize wrapped NoContent would not change observable wire behavior on supported JDKs, but would lock in correctness independent of JDK / ALPN changes.
Affected versions
Confirmed reproducing in Ktor 3.1.3 and Ktor 3.4.2 (latest stable at time of filing). Source on main is still affected (utils.kt hasContent check is unchanged).
Reproduction
Any body-less SSE GET via the CIO engine against a server fronted by AWS ELB will reproduce. Minimal:
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.sse.SSE
import io.ktor.client.plugins.sse.sseSession
val client = HttpClient(CIO) { install(SSE) }
client.sseSession("https://<aws-elb-fronted-host>/some/path")
The outgoing wire request includes:
GET /some/path HTTP/1.1
Host: <aws-elb-fronted-host>
Accept: text/event-stream
Cache-Control: no-store
Content-Length: 0 ← should not be here
and AWS ELB responds:
HTTP/1.1 400 Bad Request
Server: awselb/2.0
Content-Type: text/html
Content-Length: 122
— the request never reaches the origin application. Removing Content-Length: 0 (e.g., via curl with the same headers) makes the same path return the origin's expected response.
Empirical diff that isolates the trigger (real example, Figma's MCP server):
| Headers | Response |
|---|---|
Accept, Authorization, Accept-Charset (no Content-Length) |
404 from origin |
same + Content-Length: 0 |
400 Bad Request from awselb/2.0 |
Expected behavior
Content-Length should not appear on a body-less GET request, in line with RFC 9110 § 8.6.
Actual behavior
On CIO: Content-Length: 0 is emitted on the wire and AWS ELB returns 400 Bad Request before the request reaches the origin server.
Root cause
Three points conspire (verified against Ktor 3.4.2 sources):
-
ktor-client-core/common/src/io/ktor/client/utils/Content.kt—EmptyContent, the default outgoing body when none is set, overridescontentLength: Long = 0(notnull). This primes a downstream Content-Length value of0for any request that does not callsetBody. -
ktor-client-core/common/src/io/ktor/client/plugins/sse/SSEClientContent.kt— the SSE plugin wraps the request body inSSEClientContent, which extendsOutgoingContent.ContentWrapper, notOutgoingContent.NoContent. Engines that check "is this body-less?" viabody !is NoContenttherefore get the wrong answer for SSE GETs, even though the wrapped body is in factEmptyContent(aNoContent). -
CIO engine writes Content-Length explicitly —
ktor-client-cio/common/src/io/ktor/client/engine/cio/utils.kt,writeHeaders:val hasContent = body !is OutgoingContent.NoContent // TRUE for SSEClientContent if (contentLength != null) { if (method.supportsRequestBody || hasContent) { builder.headerLine(HttpHeaders.ContentLength, contentLength) // writes "Content-Length: 0" } }SSEClientContentis aContentWrapper, notNoContent, sohasContent = true. The guard fires and Content-Length is written. The check should unwrapContentWrapper(e.g.body.getUnwrapped() !is NoContent); notewriteBodyalready doesbody.getUnwrapped()further down, so the engine is internally inconsistent.
The Java engine reaches a correct outcome through a different path (described in the Scope section above) and is currently not observed to fail on the wire.
Spec references
- RFC 9110 § 8.6 (Content-Length) —
SHOULD NOTsend Content-Length when there is no content and the method does not anticipate it. https://www.rfc-editor.org/rfc/rfc9110#section-8.6 - RFC 9110 § 9.3.1 (GET semantics) — "Content received in a GET request has no generally defined semantics, cannot alter the meaning or target of the request, and might lead some implementations to reject the request and close the connection because of its potential as a request smuggling attack." https://www.rfc-editor.org/rfc/rfc9110#section-9.3.1
Suggested fix
In order of preference:
-
Fix at the SSE plugin layer (single, surgical change) — make
SSEClientContentextendOutgoingContent.NoContentwhen the wrapped body isNoContent. Engines' existingis NoContentchecks then fire correctly. No engine code needs to change. (Possible API/inheritance constraints — see ifContentWrapperallows this; if not, fall back to fix 2.) -
Fix in the CIO engine — change
hasContent = body !is OutgoingContent.NoContentto unwrapContentWrapper(body.getUnwrapped() !is NoContentor equivalent). Required. -
(Optional, defensive) — apply the same unwrap-aware check in the Java engine so that a future JDK change cannot regress wire behavior.
A regression test should assert the wire-level absence of Content-Length on the outgoing GET of client.sseSession("…"), running against at least CIO (and ideally Java) engines. Use ktor-test-server (or equivalent) to capture raw request headers rather than asserting against Ktor's internal request model — the bug is at the engine output, not in Ktor's request representation.
EmptyContent.contentLength = 0 itself should not be changed to null — that has a much wider blast radius and would alter behavior for every other request type. The fix should be localized to SSE / NoContent recognition.
Linked PR
Proposed fix in #5620.
Real-world impact
Breaks any Ktor-based client built on the CIO engine when it tries to open an SSE stream against any server fronted by AWS ELB with default request-smuggling protections. Specifically, the official Kotlin MCP SDK (io.modelcontextprotocol:kotlin-sdk:0.7.7) opens an SSE GET via StreamableHttpClientTransport after notifications/initialized; with HttpClient { … } (default engine = CIO), it fails against https://mcp.figma.com/mcp/<client_id> with SSEClientException: Expected status code 200 but was 400, where the 400 originates from awselb/2.0. Switching the user-side HttpClient to the Java engine works around the issue, confirming that the CIO engine is the only path exhibiting the wire-level defect.
Acceptance criteria
HttpClient(CIO).sseSession("…")against a body-less GET produces an outgoing request with noContent-Lengthheader on the wire.- A regression test enforces the above against the CIO engine (and, ideally, the Java engine too as a defensive parity).
- Non-GET SSE requests (where the spec permits a body) still emit
Content-Lengthcorrectly when there is a real body.
SaveBody performance regression
On every client, by default, we save the response body.
After caching the response in memory, we're consistently cancelling the underlying ByteReadChannel, but this constructs an IOException behind the scenes, which captures the full call stack, which is quite an expensive operation.
We just need to add a guard here so we're not erroneously cancelling the response body.
Darwin: Semicolons in URL path are sanitized
URLs like https://example.com/segment1;param=value/segment2/ are encoded on the Darwin platform and become https://example.com/segment1%3Bparam=value/segment2/.
Android/JVM platforms work correctly, they don't sanitize the semicolon character (which actually is a valid character according to RFC-3986).
Core
ByteReadChannel.readLine corrupts multi-byte UTF-8 by splitting a character at a buffer boundary
Affected versions: 3.4.0–3.5.0. Confirmed absent in 3.3.3 and present in 3.5.0.
Summary: When a single multi-byte UTF-8 character arrives split across two buffer fills of the channel, each fragment is decoded independently and replaced with U+FFFD, so one codepoint becomes two or three U+FFFD characters. This affects both readUTF8Line and readLine — and therefore the httpClient.sse {} SSE plugin. It only occurs when a line is longer than a single buffer fill, so short events are unaffected and it looks intermittent. Likely cause: KTOR-9171 (#5236) rewrote line reading to use internalReadLineTo, where, while the line's terminating LF has not yet arrived, transferString(count) (i.e. readString(count)) decodes a partial buffer in isolation.
Reproduction (unit test): Fails on every 3.4.x / 3.5.x; passes on 3.3.3.
val channel = ByteChannel(autoFlush = true)
launch {
channel.writeFully(byteArrayOf(0xE3.toByte(), 0x83.toByte())); channel.flush()
yield()
channel.writeFully(byteArrayOf(0xB3.toByte(), '\n'.code.toByte())); channel.flush()
channel.close()
}
assertEquals("ン", channel.readLine()) // expected:<[ン]> but was:<[��]>
Workaround: Replace httpClient.sse(...) with a small in-house SSE client that reads raw bytes from bodyAsChannel() and calls decodeToString() only once a full line has been accumulated. This stops the corruption.
Note: The root-cause analysis was done with an AI assistant (Claude) and may be wrong. What we have confirmed: the corruption appears when changing the ktor version, it starts to appear once a single event is made long enough, and it disappears when the workaround is used.
Inefficient text reading function in DefaultTransform
In DefaultTransformJvm we have this inefficient code:
internal actual fun Source.readTextWithCustomCharset(charset: Charset): String =
inputStream().reader(charset).readText()
This can be rewritten as readString(charset) to leverage an optimised function in kotlinx-io.
Update kotlinx-io to 0.9.1
The release contains the fix https://github.com/Kotlin/kotlinx-io/pull/506
IO
ByteReadChannel.readLineTo: Return read bytes count instead of decoded characters count
We should align the behavior of readLineTo and readLineStrictTo with the behavior initially requested in KTOR-4219.
These functions should return read bytes count to be useful for offset calculation. This also aligns with the meaning of the limit parameter as it count bytes, not decoded characters.
The functions were introduced in 3.4.0, and the return value is mostly used for checking that it's >= 0, so we decided to break this contract early rather than maintain incorrect behavior further.
It is still possible to get the count of decoded characters after this change:
// val stringBuilder: StringBuilder
// val channel: ByteReadChannel
val initialCharCount = stringBuilder.length
val byteCount = channel.readLineTo(stringBuilder)
val charCount = stringBuilder.length - initialCharCount
ByteReadChannel.readLineStrict: Misleading definition of limit parameter in KDocs
KDocs of read line functions contain the following statements:
The [limit] counts characters after UTF-8 decoding, not bytes.
...
@param limit maximum characters to append to [out]. Unlimited by default.
This is wrong, and both old and new implementations never have been working that way. The max/limit parameter limits bytes, not characters.
UTF-8 encoding in ktor-io bypasses JVM String.getBytes intrinsics, slowing every text response by 5-7x
Summary:
On the JVM, encodeToByteArray(throwOnInvalidSequence = true) is a char-by-char Kotlin loop. It cannot use String.getBytes(UTF_8), which is backed by intrinsic-accelerated code (JEP 254); an ASCII string already stores its bytes in UTF-8-compatible form, so getBytes reduces to a SIMD hasNegatives scan plus an array copy.
Project: Ktor (KTOR)
Type: Performance Problem
Subsystems: Core, ktor-io
Affected versions: 3.x
Where it happens
- every server response produced by ContentNegotiation with kotlinx-serialization or Gson (both emit
TextContent), and with Jackson when configured withstreamBody = false(the defaultstreamBody = truewrites bytes directly via Jackson'sUTF8JsonGeneratorand is not affected), - every
call.respondText(...), - JVM client request bodies sent as
TextContent, - any other
String.toByteArray()caller in ktor.
The cost scales linearly with response size and is paid once per response.
Proposed fix 1 (semantics-preserving)
The current UTF-8 branch throws CharacterCodingException on unpaired surrogates, so raw getBytes (which substitutes ?) is not a drop-in replacement. Instead:
- Scan the string once, validating surrogate pairing (a valid pair, e.g. an emoji, is skipped as a unit).
- If the string is well-formed, use
String.getBytes(UTF_8): for well-formed input it produces byte-identical output to the validating encoder. - If an unpaired surrogate is found, delegate to the existing validating encoder, which throws exactly as before.
Proposed fix 2 : adopt replacement semantics and drop the scan
Using String.getBytes(UTF_8) directly, with no validation scan, gives the full intrinsic speed: 2.3 µs instead of 29.6 µs at 100 KB (87x over current). The** behavioral difference** is that a string containing an unpaired surrogate encodes it as ? instead of throwing CharacterCodingException.
Solution comparison
| Current | Solution 1: scan + getBytes |
Solution 2: getBytes only (alternate) |
|
|---|---|---|---|
| 960 B JSON | 1,909 ns | 385 ns (5.0x) | 28 ns (69x) |
| 16 KB JSON | 32,237 ns | 5,090 ns (6.3x) | 737 ns (44x) |
| 100 KB JSON | 197,189 ns | 29,591 ns (6.7x) | 2,270 ns (87x) |
| 16 KB with emoji | 33,223 ns | 13,380 ns (2.5x) | 11,192 ns (3.0x) |
| Allocation | 2.1x payload | 1.0x (exact) | 1.0x (exact) |
| Unpaired surrogate | throws | throws (identical) | replaces with ? |
Network
Sockets: Canceling a SelectorManager job doesn't close it properly
val address = coroutineScope {
val manager = CompletableDeferred<SelectorManager>()
val managerJob = launch(Dispatchers.Default + CoroutineName("FindMongoAddressManager")) {
SelectorManager(currentCoroutineContext())
.also { manager.complete(it) }
}
val hostName = "localhost"
println(" Searching for the address of the running MongoDB instance…")
val address = InetSocketAddress(hostName, 27017)
try {
println("» Trying $address…")
aSocket(manager.await()).tcp().connect(address.hostname, address.port) {
socketTimeout = 100
}.use {
println(" Connected successfully!")
}
// manager.await().close() // HERE
managerJob.cancel("We found a valid address.")
println(" Closed the socket.")
return@coroutineScope address
} catch (e: Exception) {
println(" Could not connect to MongoDB on socket $address • $e")
}
error("Could not find on which port MongoDB is running.")
// No need to cancel the manager, it will be killed by the exception
}
println("Found address: $address")
You will need something listening on localhost:27017.
If the manager.await().close() is added, the code runs correctly: the socket connects, disconnects, and the code prints localhost:27017 after all coroutines are finished.
However, if the line is commented out, four coroutines are left hanging:
Coroutine "coroutine#76":BlockingCoroutine{Active}@3390c044, state: SUSPENDED
Coroutine TestScope[test started], state: RUNNING
Coroutine "kotlinx.coroutines.test runner#77":StandaloneCoroutine{Active}@4368e748, state: SUSPENDED
Coroutine "selector#79":StandaloneCoroutine{Cancelling}@367b4e05, state: SUSPENDED
at io.ktor.network.selector.ActorSelectorManager.receiveOrNullSuspend(ActorSelectorManager.kt:168)
at io.ktor.network.selector.ActorSelectorManager.process(ActorSelectorManager.kt:90)
at io.ktor.network.selector.ActorSelectorManager$1.invokeSuspend(ActorSelectorManager.kt:44)
at _COROUTINE._CREATION._(CoroutineDebugging.kt:30)
at kotlin.coroutines.intrinsics.IntrinsicsKt__IntrinsicsJvmKt.createCoroutineUnintercepted(IntrinsicsJvm.kt:161)
at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch$default(Builders.common.kt:44)
at kotlinx.coroutines.BuildersKt.launch$default(Unknown Source)
at io.ktor.network.selector.ActorSelectorManager.<init>(ActorSelectorManager.kt:39)
at io.ktor.network.selector.SelectorManagerKt.SelectorManager(SelectorManager.kt:13)
at …the line in the example with 'SelectorManager(currentCoroutineContext())'
The lifecycle of the SelectorManager should be controllable through structured concurrency garantees.
Server
CIO: request-handler coroutine leak on half-closed idle connection after ≥1MB response
To reproduce, execute the following test:
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.server.application.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*
import io.ktor.server.metrics.micrometer.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.util.*
import io.micrometer.prometheusmetrics.PrometheusConfig
import io.micrometer.prometheusmetrics.PrometheusMeterRegistry
import kotlinx.coroutines.CoroutineName
import kotlin.test.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
class ServerTest {
@Test
fun `real server leaks coroutines`() = runTest(timeout = 10.minutes) {
val metrics = PrometheusMeterRegistry(PrometheusConfig.DEFAULT)
val job = Job()
val server =
embeddedServer(
CIO,
rootConfig = serverConfig {
parentCoroutineContext = job + CoroutineName("server-parent")
module {
install(MicrometerMetrics) { registry = metrics }
routing { get("/") { call.respondText("A".repeat(1024 * 1024)) } } // 1,048,467
}
},
) {
connectionIdleTimeoutSeconds = 2
connector {
host = "localhost"
port = 14800
}
}
try {
server.start(wait = false)
val root = server.engine.resolvedConnectors().first().let { "http://${it.host}:${it.port}" }
val timeMark = TimeSource.Monotonic.markNow()
fun log(msg: String) = println("[${timeMark.elapsedNow()}] $msg")
suspend fun realDelay(duration: Duration) = withContext(Dispatchers.Default) { delay(duration) }
log("server running on $root")
val monitor = backgroundScope.launch {
while (isActive) {
metrics.scrape().lineSequence().filter { it.startsWith("ktor_http_server_requests_active") }
.forEach(::log)
job.printDebugTree()
realDelay(1.seconds)
}
}
HttpClient(OkHttp) { expectSuccess = true }
.use { httpClient ->
repeat(4) {
log("---- requesting...")
httpClient.get(root).bodyAsText().also { log("received: ${it.length}") }
realDelay(4.seconds)
}
log("closing client...")
}
realDelay(4.seconds)
monitor.cancel()
} finally {
server.stop(0, 0)
job.printDebugTree()
job.complete()
}
}
}
As a result, the CIO server leaks request-handler couroutines:
[5.137217667s] ktor_http_server_requests_active 1.0
JobImpl{Active}@6e4c0d8c
SupervisorJobImpl{Active}@5c5d6175
"server-parent#3":LazyStandaloneCoroutine{Active}@7544ac86
"server-root-HttpServerSettings(host=localhost, port=14800, connectionIdleTimeoutSeconds=2, reuseAddress=false)#7":StandaloneCoroutine{Active}@3b27b497
SupervisorJobImpl{Active}@b1534d3
"http-pipeline#15":StandaloneCoroutine{Completing}@4e49ce2b
"request-handler#20":StandaloneCoroutine{Active}@435cc7f9
"request-handler#20":DispatchedCoroutine{Active}@4364712f
"request-handler#20":ScopeCoroutine{Active}@1b7a52dd
"request-handler#20":DispatchedCoroutine{Active}@7f93dd4e
"http-pipeline#23":StandaloneCoroutine{Active}@5ad5be4a
"http-pipeline-writer#24":StandaloneCoroutine{Active}@3ad85136
"http-pipeline-writer#24":TimeoutCoroutine(timeMillis=2000){Active}@737d100a
"accept-HttpServerSettings(host=localhost, port=14800, connectionIdleTimeoutSeconds=2, reuseAddress=false)#9":StandaloneCoroutine{Active}@3c74aa0d
"selector#8":StandaloneCoroutine{Active}@6c841199
The problem is reproducible only when the server responds with 1Mb of data or more and when the requests are sent with the OkHttp engine.
Maven + YAML config file does not work
When running Ktor from maven with a bundled config file, the server fails with the following:
Exception in thread "main" java.lang.IllegalArgumentException: Neither port nor sslPort specified. Use command line options -port/-sslPort or configure connectors in application.conf
at io.ktor.server.engine.CommandLineKt.CommandLineConfig(CommandLine.kt:72)
at io.ktor.server.cio.EngineMain.main(EngineMain.kt:20)
I discovered this when testing the generator and later realized that it is also affecting production code.
OpenAPI: JsonSchema title is truncated when it contains a dot
After updating to version 3.4.3 from 3.4.2, when generating OpenAPI, scheme titles are truncated if they contain a dot symbol. In both versions, scheme names are also truncated.
Models and method setup:
@Serializable
@SerialName("DTO.Bar1")
data class Foo1(val value: String)
@Serializable
@JsonSchema.Title("DTO.Bar2")
data class Foo2(val value: String)
get("/test") {
call.respond(HttpStatusCode.OK)
}.describe {
responses {
HttpStatusCode.OK {
schema = jsonSchema<Foo1>()
}
HttpStatusCode.Created {
schema = jsonSchema<Foo2>()
}
}
}
Result:
"schemas": {
"Bar1": {
"type": "object",
"title": "Bar1",
"required": [
"value"
],
"properties": {
"value": {
"type": "string"
}
}
},
"Bar2": {
"type": "object",
"title": "Bar2",
"required": [
"value"
],
"properties": {
"value": {
"type": "string"
}
}
}
}
Result for 3.4.2 version:
"schemas": {
"Bar1": {
"type": "object",
"title": "DTO.Bar1",
"required": [
"value"
],
"properties": {
"value": {
"type": "string"
}
}
},
"Bar2": {
"type": "object",
"title": "DTO.Bar2",
"required": [
"value"
],
"properties": {
"value": {
"type": "string"
}
}
}
}
HoconConfigLoader is not loaded when ktor-server-config-yaml on the classpath
When building a fat JAR with ktor-server-config-yaml on the classpath, we observed that YamlConfigLoader gets selected and application.conf is not loaded. (It took several hours to solve this problem...)
This looks related to how META-INF/services files are merged for a fat JAR and how JVM ServiceLoader resolves providers, rather than a Ktor-specific bug.
I saw some issues referencing this behavior like
- https://youtrack.jetbrains.com/issue/KTOR-6610/Log-which-ConfigLoader-has-been-used-for-loading-the-server-configuration
- https://kotlinlang.slack.com/archives/C0A974TJ9/p1736451975122839
As Patrik Csikós says in the YouTrack issue comment, I thought it would be beneficial to take these actions below.
(A) Improve logging: when multiple ConfigLoaders are discovered, log the list of candidates and the reason one was selected.
(B) Document the behavior when including multiple ConfigLoader on the classpath.
OpenAPI: schema for kotlinx.serialization sealed types omits discriminator property in variant schemas
Summary
OpenAPI schema inference for kotlinx.serialization sealed types emits a discriminator object on the sealed parent schema but does not include the discriminator property in each variant schema's properties / required.
This makes the generated schema inconsistent with the JSON shape produced by kotlinx.serialization.
Expected behavior
For a sealed type with @JsonClassDiscriminator("kind"), each subtype schema should include kind as a required property, because kotlinx.serialization emits it in the serialized JSON.
Example expected subtype schema:
{
"type": "object",
"required": ["kind", "radius"],
"properties": {
"kind": {
"type": "string",
"enum": ["circle"]
},
"radius": {
"type": "number"
}
}
}
Actual behavior
Ktor generates the parent schema with oneOf and discriminator, but the subtype schemas do not include the discriminator property.
Example actual subtype schema:
{
"type": "object",
"required": ["radius"],
"properties": {
"radius": {
"type": "number"
}
}
}
The parent schema contains:
discriminator:
propertyName: kind
mapping:
circle: "#/components/schemas/io.ktor.openapi.reflect.KindShape.Circle"
rectangle: "#/components/schemas/io.ktor.openapi.reflect.KindShape.Rectangle"
Reproducer
@OptIn(ExperimentalSerializationApi::class)
@JsonClassDiscriminator("kind")
@Serializable
sealed interface KindShape {
@Serializable
@SerialName("circle")
data class Circle(val radius: Double) : KindShape
@Serializable
@SerialName("rectangle")
data class Rectangle(val width: Double, val height: Double) : KindShape
}
Additional note
This also affects nested sealed types. For example, if one sealed type contains a property whose type is another sealed type, both discriminator objects are generated, but neither discriminator property is included in the corresponding variant schemas.
Netty: Close channel connection on errors properly
For instance, in NettyApplicationCallHandler:
private fun respond408RequestTimeout(ctx: ChannelHandlerContext) {
val response = DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.REQUEST_TIMEOUT)
response.headers().add(HttpHeaders.ContentLength, "0")
response.headers().add(HttpHeaders.Connection, "close")
ctx.writeAndFlush(response)
ctx.close()
}
You are scheduling response write in socket and then immediately close the connection. So, when Netty will try to write the data the connection might be closed and the end user will get broken pipe error.
Consider to use:
private fun respond408RequestTimeout(ctx: ChannelHandlerContext) {
val response = DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.REQUEST_TIMEOUT)
response.headers().add(HttpHeaders.ContentLength, "0")
response.headers().add(HttpHeaders.Connection, "close")
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE)
}
Netty HTTP/2: a single client-canceled stream (RST_STREAM) breaks response flushing for the whole connection
Affected versions
- Reproduced against Ktor 3.5.1 (latest release at the time of writing) with the attached
self-contained reproducer (plaintext h2c, no TLS needed; also reproduces over TLS/ALPN). - The responsible code is unchanged on main (3.6.0-SNAPSHOT, commit
fc6595632e7412abb98b941f926d0ea13c7647d1), so the analysis below links to main.
Symptom
On any HTTP/2 connection served by the Netty engine: if the client cancels one in-flight
request with RST_STREAM before the application commits its response (browsers do this
constantly — hard refresh, navigation away, AbortController, image lazy-load cancels),
then every subsequent response multiplexed on that connection is corrupted:
- responses with unknown length (chunked /
respondTextWriter/ SSE-like): the client
receives all body bytes but the final emptyDATA(endStream=true)frame is never
flushed — the request never completes and the browser spins forever; - responses with a known body ≤ 64 KiB (the common case for HTML/JSON): nothing is
flushed at all, not even headers; - the connection never recovers until it is closed.
In Chrome this looks like: after refresh-spamming a page, some requests show the complete
payload in DevTools but stay "pending" forever.
Root cause
NettyHttp2Handler is @ChannelHandler.Sharable and is installed once per connection via
Http2MultiplexCodecBuilder.forServer(handler)
(NettyChannelInitializer.kt:193),
so one handler instance is shared by all Http2StreamChannels of the connection, and with it:
state: NettyHttpHandlerState—activeRequests/streamingResponses/
isCurrentRequestFullyRead/isChannelReadCompletedare connection-global
(NettyHttp2Handler.kt:49);responseWriter: NettyHttpResponsePipeline(lateinit var) is overwritten on every
stream'schannelActive
(NettyHttp2Handler.kt:84-89),
sochannelReadCompleteonly ever callsflushIfNeeded()on the newest stream's pipeline
instance, whileisDataNotFlushedof older streams' pipelines is tracked on instances that
are no longer consulted.
The final frame of every HTTP/2 response — the empty DefaultHttp2DataFrame(endStream=true) —
is written with context.write(...) (not writeAndFlush) in handleLastResponseMessage
(NettyHttpResponsePipeline.kt:145-151)
and relies on scheduleFlush() → flushIfNeeded(), which is gated by:
isDataNotFlushed.value &&
httpHandlerState.isChannelReadCompleted.value &&
httpHandlerState.activeRequests.value == httpHandlerState.streamingResponses.value
(NettyHttpResponsePipeline.kt:56-65)
The leak: activeRequests is incremented for every Http2HeadersFrame
(NettyHttp2Handler.kt:56)
but decremented only in handleLastResponseMessage. The failure path
respondWithFailure
(NettyHttpResponsePipeline.kt:107-122)
decrements neither activeRequests nor streamingResponses.
Sequence for a cancelled stream (default server, no plugins involved):
- client sends
HEADERS→activeRequests++; - client sends
RST_STREAM(CANCEL)→Http2MultiplexCodeccloses the stream's child
channel; the ktor handler only closes the requestcontentActor
(NettyHttp2Handler.kt:73-78); - the application handler later completes normally and commits a response →
respondFromBytes/sendResponseseescanRespond == false(channel inactive) →
cancelIfChannelNotActive()→cancel()→responseReady.tryFailure(...)
(NettyApplicationResponse.kt:155-167
— this is the fail-fast path introduced/hardened by KTOR-9524 / KTOR-9536; the engine's
AFTER_CALLcall.finish()guarantees this promise always resolves); - the
responseReadylistener routes the failed promise intorespondWithFailure
(NettyHttpResponsePipeline.kt:91-105)
→activeRequestsis never decremented; - from now on
activeRequests == streamingResponses + 1forever on this connection →
flushIfNeeded()is permanently false → every later response's trailing
DATA(endStream=true)(and, for small responses, headers + body too, since
isHeaderFlushNeeded()also requiresactiveRequests == 1) sits in the outbound buffer
indefinitely. The only bytes that still reach the client are the incidental
writeAndFlushcalls insiderespondWithBigBody(64 KiB threshold / drained channel)
— which is exactly why the body arrives completely while the stream never completes.
A second, milder variant: when a streaming response fails mid-write (e.g. the write into
a reset stream throws), respondWithFailure leaks activeRequests and streamingResponses
together, which keeps the flush gate balanced but permanently breaks the
isHeaderFlushNeeded() heuristic (activeRequests == 1L).
The counters were designed for HTTP/1 (one connection = one request at a time, connection
close cleans up); HTTP/2 shares them across concurrent streams whose lifetimes are
independent, and RST_STREAM makes premature stream death a routine event rather than a
connection-fatal one. Related earlier fix in the same area: KTOR-9421 ("active SSE connection
blocks HTTP/2 response flushing for other requests") addressed the case where a live
streaming response holds activeRequests above zero by introducing the streamingResponses
counter — but the failure/cancellation path was left out of the bookkeeping.
Reproducer (attached, self-contained)
gradle run starts a ktor 3.5.1 Netty server with enableH2c = true on 127.0.0.1:18080 and
drives it with a raw Netty h2c prior-knowledge client on the same connection:
- phase 1:
GET /slow(handler delays 400 ms), client sendsRST_STREAM(CANCEL)after 100 ms; - phase 2:
GET /data(~8 KiB viarespondTextWriter); - phase 3:
GET /slowagain.
Observed output against 3.5.1:
== phase 1: GET /slow, cancel it with RST_STREAM after 100ms (simulates browser refresh) ==
== phase 2: GET /data (chunked ~8KB) on the same connection ==
[phase 2] GET /data -> headers=true bodyBytes=8200 endStream=false *** HUNG (timeout 5s) ***
== phase 3: GET /slow again on the same connection ==
[phase 3] GET /slow -> headers=false bodyBytes=0 endStream=false *** HUNG (timeout 3s) ***
RESULT: BUG REPRODUCED - connection is permanently poisoned after one cancelled stream:
- phase 2 received the body but never END_STREAM (browser would spin forever)
- phase 3 got no response at all (headers never flushed)
Control run (gradle run --args=control, identical except no RST_STREAM):
[phase 1] GET /slow -> headers=true bodyBytes=18 endStream=true COMPLETED
[phase 2] GET /data -> headers=true bodyBytes=8200 endStream=true COMPLETED
[phase 3] GET /slow -> headers=true bodyBytes=18 endStream=true COMPLETED
RESULT: all requests completed - no bug observed
Suggested fix directions
-
Minimal: perform the same state bookkeeping in
respondWithFailureas in
handleLastResponseMessage(decrementactiveRequestsviaonLastResponseMessage, and
streamingResponseswhencall.isStreamingResponse), so a failed/cancelled call cannot
desynchronize the connection-global gate. -
Structural: give each HTTP/2 stream its own
NettyHttpHandlerState+ response pipeline
(eachHttp2StreamChannelis already a separate Netty channel; the cross-request ordering
and flush-coalescing heuristics inherited from the HTTP/1 pipelining design don't apply to
multiplexed streams). This would also fixresponseWriterbeing reassigned per stream on
the shared@Sharablehandler, which currently leavesisDataNotFlushedtracked on
pipeline instances thatchannelReadCompleteno longer consults, and it makes the
streamingResponsesworkaround from KTOR-9421 unnecessary (with at most one request per
state, the counters cannot be held hostage by an unrelated stream).For what it's worth, we have been running exactly this shape in production since March 2026
as a vendored copy ofktor-server-netty:NettyHttp2Handlerloses@Sharableand is
instantiated per stream by aChannelInitializer<Http2StreamChannel>handed to
Http2MultiplexHandler, each instance owning its ownNettyHttpHandlerStateand
NettyHttpResponsePipeline; combined with an explicitcontext.flush()on
HEADERS(endStream) / DATA(endStream) / RST_STREAM it eliminated the hang completely under
heavy browser refresh-spam.
RateLimit: No access to principal when wrapped with authentication since 3.5.1
Ktor 3.5.1 introduce a fix of RateLimit https://youtrack.jetbrains.com/issue/KTOR-9621
But this fix breaks behaviour when RateLimit is after Authentication (bearer in my scheme) in the route: now RateLimit is always called before authentication and it has no access to principal (principal is filled during authentication phase)
fun RateLimitConfig.registerLimits() {
register(RateLimitName("MyRateLimit")) {
rateLimiter(limit = 10, refillPeriod = 1.seconds)
requestKey { call ->
print("Executed BEFORE auth!") // <-- this is called before authentication
call.authentication.principal<UserIdPrincipal>()?.name!! // <-- throws exception
}
}
}
and the route
fun Route.addCurrent() = route("/current") {
val service by application.inject<MyService>()
authenticate("BearerUserScheme") {
rateLimit(RateLimitName("MyRateLimit")) {
get {
val view = service.getCurrent(call.authentication.principal<UserIdPrincipal>()?.name!!)
call.respond(view)
}
}
}
}
`ApplicationCall.isStaticContent` returns false when called from plugin interceptors
Api symbol: io.ktor.server.http.content.StaticFileLocationProperty:
call.isStaticContent() is never true. I don't think the attribute is internally set
To reproduce start the following server and request a resource from the static directory:
embeddedServer(Netty, 8084) {
install(CallId) {
retrieve { call ->
println("Is static: ${call.isStaticContent()}") // Always false
"id"
}
}
routing {
staticFiles("/files", File("static"))
}
}.start(wait = true)
As a result, the ApplicationCall.isStaticContent returns false.
RateLimit: Allow limit requests based on authentication result
Currently, it is not possible to filter rate-limits by the authentication status of a request.
Such a feature would be useful when you want to allow for routes to be still publicly accessible, however you wish to use more aggressive rate limits for unauthenticated users.
Here is some example code showcasing the issue:
private val logger by getLogger()
sealed interface SyrupPrincipal {
val authenticated: Boolean
val name: String
}
data class AuthenticatedSyrupUser(
override val name: String,
) : SyrupPrincipal {
override val authenticated = true
}
data object UnauthenticatedSyrupUser : SyrupPrincipal {
override val authenticated = false
override val name = "Anonymous User"
}
install(Authentication) {
bearer {
authHeader { call ->
logger.info { "Parsing authentication header" }
call.request.parseAuthorizationHeader() ?: HttpAuthHeader.Single(AuthScheme.Bearer, "Unauthenticated")
}
authenticate { bearer ->
logger.info { "bearer: $bearer" }
when (bearer.token) {
"Unauthenticated" -> UnauthenticatedSyrupUser
else -> AuthenticatedSyrupUser(bearer.token) // TODO: 2025-01-07 Actual authentication
}
}
}
}
install(RateLimit) {
global {
requestKey { call ->
val principal = call.principal<SyrupPrincipal>()
logger.info { "key request called with $principal" }
if (principal?.authenticated == true) principal.name else call.request.origin.remoteAddress
}
rateLimiter { call, key ->
val principal = call.principal<SyrupPrincipal>()
val limit = if (principal?.authenticated == true) 256 else 64
logger.info { "rate limiter called with $principal" }
// the redis rate limiter is just a simple rate limiter that checks redis for the remaining requests
redisRateLimiter(key.toString(), limit, 5.seconds, Syrup.redisPool)
}
}
}
If you perform a request using (assuming ktor is launched on the default port)
curl localhost:8080/test -v -H 'Authorization: Bearer 123'
then, this will always result in the following logs:
2025-01-09 00:53:05.434 [eventLoopGroupProxy-6-1 @call-handler#107] TRACE i.k.s.plugins.ratelimit.RateLimit - Using rate limit RateLimitName(name=KTOR_GLOBAL_RATE_LIMITER) for /test
2025-01-09 00:53:05.436 [eventLoopGroupProxy-6-1 @call-handler#107] INFO g.solonovamax.syrup.ktor.Responses - key request called with null
2025-01-09 00:53:05.436 [eventLoopGroupProxy-6-1 @call-handler#107] TRACE i.k.s.plugins.ratelimit.RateLimit - Using key=0:0:0:0:0:0:0:1 and weight=1 for /test
2025-01-09 00:53:05.436 [eventLoopGroupProxy-6-1 @call-handler#107] INFO g.solonovamax.syrup.ktor.Responses - rate limiter called with null
2025-01-09 00:53:05.449 [eventLoopGroupProxy-6-1 @call-handler#107] TRACE i.k.s.plugins.ratelimit.RateLimit - Allowing /test
2025-01-09 00:53:05.450 [eventLoopGroupProxy-6-1 @call-handler#107] TRACE io.ktor.server.routing.Routing - Trace for [test]
/, segment:0 -> SUCCESS @ /
/(authenticate "default"), segment:0 -> SUCCESS @ /(authenticate "default")
/(authenticate "default")/test, segment:1 -> SUCCESS @ /(authenticate "default")/test
/(authenticate "default")/test/(method:GET), segment:1 -> SUCCESS @ /(authenticate "default")/test/(method:GET)
Matched routes:
"" -> "(authenticate "default")" -> "test" -> "(method:GET)"
Routing resolve result:
SUCCESS @ /(authenticate "default")/test/(method:GET)
2025-01-09 00:53:05.450 [eventLoopGroupProxy-6-1 @call-handler#107] TRACE io.ktor.server.auth.Authentication - Trying to authenticate /test with null
2025-01-09 00:53:05.450 [eventLoopGroupProxy-6-1 @call-handler#107] INFO gay.solonovamax.syrup.ktor.Security - Here is auth header: Bearer 123
2025-01-09 00:53:05.451 [eventLoopGroupProxy-6-1 @call-handler#107] INFO gay.solonovamax.syrup.ktor.Security - bearer: BearerTokenCredential(token=123)
2025-01-09 00:53:05.451 [eventLoopGroupProxy-6-1 @call-handler#107] TRACE io.ktor.server.auth.Authentication - Authentication succeeded for /test with provider io.ktor.server.auth.BearerAuthenticationProvider@60a18ffc
The most important log messages to note are:
2025-01-09 00:53:05.434 [eventLoopGroupProxy-6-1 @call-handler#107] TRACE i.k.s.plugins.ratelimit.RateLimit - Using rate limit RateLimitName(name=KTOR_GLOBAL_RATE_LIMITER) for /test
2025-01-09 00:53:05.436 [eventLoopGroupProxy-6-1 @call-handler#107] INFO g.solonovamax.syrup.ktor.Responses - key request called with null
2025-01-09 00:53:05.436 [eventLoopGroupProxy-6-1 @call-handler#107] TRACE i.k.s.plugins.ratelimit.RateLimit - Using key=0:0:0:0:0:0:0:1 and weight=1 for /test
2025-01-09 00:53:05.436 [eventLoopGroupProxy-6-1 @call-handler#107] INFO g.solonovamax.syrup.ktor.Responses - rate limiter called with null
2025-01-09 00:53:05.449 [eventLoopGroupProxy-6-1 @call-handler#107] TRACE i.k.s.plugins.ratelimit.RateLimit - Allowing /test
2025-01-09 00:53:05.450 [eventLoopGroupProxy-6-1 @call-handler#107] TRACE io.ktor.server.auth.Authentication - Trying to authenticate /test with null
2025-01-09 00:53:05.450 [eventLoopGroupProxy-6-1 @call-handler#107] INFO gay.solonovamax.syrup.ktor.Security - Here is auth header: Bearer 123
2025-01-09 00:53:05.451 [eventLoopGroupProxy-6-1 @call-handler#107] INFO gay.solonovamax.syrup.ktor.Security - bearer: BearerTokenCredential(token=123)
2025-01-09 00:53:05.451 [eventLoopGroupProxy-6-1 @call-handler#107] TRACE io.ktor.server.auth.Authentication - Authentication succeeded for /test with provider io.ktor.server.auth.BearerAuthenticationProvider@60a18ffc
As you can see, it is attempting to resolve the rate limit before the authentication has been processed.
Auto-reloading: server is reloaded only once since 3.5.0
To reproduce, start the application from the attached project and make changes/rebuild multiple times.
As a result, the second and consequent auto-reloads don't happen.
These changes might have caused the regression.
SwaggerUI: Missing oauth2-redirect.html support in the plugin
Description
The Ktor Swagger UI plugin does not provide the necessary oauth2-redirect.html file for OAuth2 security flows. This causes issues when configuring OAuth2 in Swagger UI because the redirection required for authorization flow completion is missing, leading to failed authentication attempts.
Steps to Reproduce
1. Configure the Ktor Swagger UI plugin with OAuth2 security as described in the Swagger UI documentation.
2. Open the Swagger UI in a browser.
3. Attempt to use the “Authorize” button with an OAuth2 flow.
Expected Behavior
Swagger UI should provide an oauth2-redirect.html endpointor the capability to handle the redirection process properly, allowing OAuth2 flows to complete successfully.
Actual Behavior
The authorization flow fails because the oauth2-redirect.html file or its equivalent is not available. Swagger UI cannot complete the OAuth2 redirection.
Impact
This issue prevents users from integrating and testing OAuth2-secured APIs effectively within Swagger UI when using the Ktor Swagger UI plugin.
Current Workaround
As a temporary solution, I am hosting the oauth2-redirect.html file using Ktor’s staticResources feature. While this resolves the issue, it requires additional manual configuration and is not an intuitive or integrated approach.
Improve KDoc for EmbeddedServer.addShutdownHook
API symbol: io.ktor.server.engine.addShutdownHook:
The documentation for this method should describe whether it is possible to add multiple shutdown hooks, e.g. in different modules in an application.
The documentation is vague on exactly when the hook is called. Is it called on ApplicationStopPreparing, or on ApplicationStopped?
Shared
ContentNegotiation: it doesn't handle content type suffixes
The ContentType (and so ContentNegotiation) class does not handle suffixes, except the hard coded JsonContentTypeMatcher, without an option to add, change or remove the hardcoded type matcher.
// pseudo code
testApplication {
routing {
if (error) {
call.respondText("someProblemXML", ContentType.Application.ProblemXml)
} else {
call.respondText("someXml", ContentType.Application.Xml)
}
}
createClient {
install(ContentNegotiation) {
register(ContentType.Application.Xml, XML)
}
}.get {}
The client will send Accept: application/xml, but the server will return Content-Type: application/problem+xml resulting into a ContentConverterException because there is no registered converter for application/problem+xml.
IMHO it should use the application/xml as fallback, and Content-Type.match should support the suffix:
assertTrue(ContentType.Application.ProblemJson.match(ContentType.Application.Json))
Other
Reject whitespace in URL hosts during parsing
parseUrl accepts malformed URLs whose host contains whitespace, although it is documented to return null for invalid URLs.
Examples:
parseUrl("http:// example.com")
parseUrl("http://exa mple.com")
parseUrl("http://example.com :8080")
These currently produce a Url instead of being rejected.
Validate the host extracted by URLParser and reject it when it contains whitespace. For parseUrl, the validation failure should result in null. Add regression tests covering whitespace before, within, and after the hostname.
This affects the shared ktor-http URL parser used by both clients and servers.
Pull request: https://github.com/ktorio/ktor/pull/5744
3.5.1
released 29th June 2026
Client
HttpTimeout: HttpRequestTimeoutException on any request with runTest and request timeout defined since 3.5.0
After update from 3.4.3 to 3.5.0, some tests that use MockEngine started throwing HttpRequestTimeoutException. Also, tests that assert whether exception was thrown became flaky (sometimes HttpRequestTimeoutException is thrown instead of the expected one).
Commit with failing tests
Affected tests are in BackendApiClientTest and QuizApiClientTest classes.
Example output:
Request timeout has expired [url=https://abcde:12345/answers, request_timeout=10000 ms]
io.ktor.client.plugins.HttpRequestTimeoutException: Request timeout has expired [url=https://abcde:12345/answers, request_timeout=10000 ms]
at app//io.ktor.client.plugins.HttpTimeoutKt$applyRequestTimeout$killer$1.invokeSuspend(HttpTimeout.kt:186)
at app//kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
at app//kotlinx.coroutines.DispatchedTaskKt.resume(DispatchedTask.kt:233)
at app//kotlinx.coroutines.DispatchedTaskKt.dispatch(DispatchedTask.kt:152)
at app//kotlinx.coroutines.CancellableContinuationImpl.dispatchResume(CancellableContinuationImpl.kt:470)
at app//kotlinx.coroutines.CancellableContinuationImpl.resumeImpl$kotlinx_coroutines_core(CancellableContinuationImpl.kt:504)
at app//kotlinx.coroutines.CancellableContinuationImpl.resumeImpl$kotlinx_coroutines_core$default(CancellableContinuationImpl.kt:493)
at app//kotlinx.coroutines.CancellableContinuationImpl.resumeUndispatched(CancellableContinuationImpl.kt:596)
at app//kotlinx.coroutines.test.CancellableContinuationRunnable.run(TestDispatcher.kt:58)
at app//kotlinx.coroutines.test.TestDispatcher.processEvent$kotlinx_coroutines_test(TestDispatcher.kt:24)
at app//kotlinx.coroutines.test.TestCoroutineScheduler.tryRunNextTaskUnless$kotlinx_coroutines_test(TestCoroutineScheduler.kt:98)
at app//kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$workRunner$1.invokeSuspend(TestBuilders.kt:326)
at app//kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
at app//kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
at app//kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:256)
at app//kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:54)
at app//kotlinx.coroutines.BuildersKt__BuildersKt.runBlockingImpl(Builders.kt:30)
at app//kotlinx.coroutines.BuildersKt.runBlockingImpl(Unknown Source)
at app//kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK(Builders.concurrent.kt:172)
at app//kotlinx.coroutines.BuildersKt.runBlockingK(Unknown Source)
at app//kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK$default(Builders.concurrent.kt:157)
at app//kotlinx.coroutines.BuildersKt.runBlockingK$default(Unknown Source)
at app//kotlinx.coroutines.test.TestBuildersJvmKt.createTestResult(TestBuildersJvm.kt:10)
at app//kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:309)
at app//kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:1)
at app//kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:167)
at app//kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:1)
at app//kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0$default(TestBuilders.kt:159)
at app//kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0$default(TestBuilders.kt:1)
at app//com.example.techquiz.data.remote.client.BackendApiClientTest.GIVEN user UUID and data WHEN save answers THEN return(BackendApiClientTest.kt:47)
at java.base@21.0.10/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base@21.0.10/java.lang.reflect.Method.invoke(Unknown Source)
at app//org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at app//org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at app//org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at app//org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at app//org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
at app//org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at app//org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at app//org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at app//org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at app//org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at app//org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at app//org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at app//org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at app//org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at app//org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at app//org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at app//org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at app//org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at app//org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base@21.0.10/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base@21.0.10/java.lang.reflect.Method.invoke(Unknown Source)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at app//worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at app//worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
Output from one of the flaky tests:
org.opentest4j.AssertionFailedError: Expected exception io.ktor.client.plugins.ClientRequestException but a HttpRequestTimeoutException was thrown instead.
at io.kotest.assertions.AssertionErrorBuilder_jvmKt.createAssertionError(AssertionErrorBuilder.jvm.kt:58)
at io.kotest.assertions.AssertionErrorBuilder.build(AssertionErrorBuilder.kt:74)
at com.example.techquiz.data.remote.client.BackendApiClientTest$GIVEN user UUID and error WHEN get most answered categories THEN throw$1.invokeSuspend(BackendApiClientTest.kt:273)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:98)
at kotlinx.coroutines.test.TestDispatcher.processEvent$kotlinx_coroutines_test(TestDispatcher.kt:24)
at kotlinx.coroutines.test.TestCoroutineScheduler.tryRunNextTaskUnless$kotlinx_coroutines_test(TestCoroutineScheduler.kt:98)
at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$workRunner$1.invokeSuspend(TestBuilders.kt:326)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:256)
at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:54)
at kotlinx.coroutines.BuildersKt__BuildersKt.runBlockingImpl(Builders.kt:30)
at kotlinx.coroutines.BuildersKt.runBlockingImpl(Unknown Source)
at kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK(Builders.concurrent.kt:172)
at kotlinx.coroutines.BuildersKt.runBlockingK(Unknown Source)
at kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK$default(Builders.concurrent.kt:157)
at kotlinx.coroutines.BuildersKt.runBlockingK$default(Unknown Source)
at kotlinx.coroutines.test.TestBuildersJvmKt.createTestResult(TestBuildersJvm.kt:10)
at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:309)
at kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:1)
at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:167)
at kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:1)
at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0$default(TestBuilders.kt:159)
at kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0$default(TestBuilders.kt:1)
at com.example.techquiz.data.remote.client.BackendApiClientTest.GIVEN user UUID and error WHEN get most answered categories THEN throw(BackendApiClientTest.kt:138)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
Caused by: io.ktor.client.plugins.HttpRequestTimeoutException: Request timeout has expired [url=https://abcde:12345/stats/most_answered_categories?userUuid=b0dc1232-72ca-40c1-9dca-9a886ef3c99b&count=3, request_timeout=10000 ms]
at app//io.ktor.client.plugins.HttpTimeoutKt$applyRequestTimeout$killer$1.invokeSuspend(HttpTimeout.kt:186)
at app//kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
at app//kotlinx.coroutines.DispatchedTaskKt.resume(DispatchedTask.kt:233)
at app//kotlinx.coroutines.DispatchedTaskKt.dispatch(DispatchedTask.kt:152)
at app//kotlinx.coroutines.CancellableContinuationImpl.dispatchResume(CancellableContinuationImpl.kt:470)
at app//kotlinx.coroutines.CancellableContinuationImpl.resumeImpl$kotlinx_coroutines_core(CancellableContinuationImpl.kt:504)
at app//kotlinx.coroutines.CancellableContinuationImpl.resumeImpl$kotlinx_coroutines_core$default(CancellableContinuationImpl.kt:493)
at app//kotlinx.coroutines.CancellableContinuationImpl.resumeUndispatched(CancellableContinuationImpl.kt:596)
at app//kotlinx.coroutines.test.CancellableContinuationRunnable.run(TestDispatcher.kt:58)
... 64 more
Caching always missing with Content Negotiation
When a Vary header contains multiple values, requests always generate cache-misses and If-None-Match / If-Modified-Since are never sent.
Use Case
When HttpCache and ContentNegotiation are both installed, responses with Accept in the Vary header (e.g. Vary: Accept) are never served from cache if the client specifies a specific Accept header.
e.g. to make requests to github, the client should use Accept: application/vnd.github+json but the ContentNegotiation plugin also unconditionally appends an Accept for every registered codec:
registrations.forEach { request.accept(it.contentTypeToSend) }
When the caller also sets Accept (e.g. accept(ContentType.parse("application/vnd.github+json"))), the request ends up with two Accept values. The stored varyKey and the lookup key then differ only in separator:
| Value | |
|---|---|
Stored (joinToString(",")) |
"application/vnd.github+json,application/json" |
Lookup (joinToString(";")) |
"application/vnd.github+json;application/json" |
WebRtcPeerConnection::close causes ClosedReceiveChannelException for other peer
If a WebRtcPeerConnection, with an active data channel, is closed, the other peer crashes with a ClosedReceiveChannelException :
kotlinx.coroutines.channels.ClosedReceiveChannelException: Channel was closed
at kotlinx.coroutines.channels.BufferedChannel.getReceiveException(BufferedChannel.kt:1739)
at kotlinx.coroutines.channels.BufferedChannel.resumeWaiterOnClosedChannel(BufferedChannel.kt:2181)
at kotlinx.coroutines.channels.BufferedChannel.resumeReceiverOnClosedChannel(BufferedChannel.kt:2170)
at kotlinx.coroutines.channels.BufferedChannel.cancelSuspendedReceiveRequests(BufferedChannel.kt:2163)
at kotlinx.coroutines.channels.BufferedChannel.completeClose(BufferedChannel.kt:1940)
at kotlinx.coroutines.channels.BufferedChannel.isClosed(BufferedChannel.kt:2219)
at kotlinx.coroutines.channels.BufferedChannel.isClosedForSend0(BufferedChannel.kt:2194)
at kotlinx.coroutines.channels.BufferedChannel.isClosedForSend(BufferedChannel.kt:2191)
at kotlinx.coroutines.channels.BufferedChannel.completeCloseOrCancel(BufferedChannel.kt:1912)
at kotlinx.coroutines.channels.BufferedChannel.closeOrCancelImpl(BufferedChannel.kt:1805)
at kotlinx.coroutines.channels.BufferedChannel.close(BufferedChannel.kt:1764)
at kotlinx.coroutines.channels.SendChannel.close$default(Channel.kt:263)
at io.ktor.client.webrtc.WebRtcDataChannel.stopReceivingMessages(WebRtcDataChannel.kt:225)
at io.ktor.client.webrtc.AndroidWebRtcDataChannel$setupEvents$1$onStateChange$$inlined$runInConnectionScope$1.invokeSuspend(DataChannel.kt:144)
at io.ktor.client.webrtc.AndroidWebRtcDataChannel$setupEvents$1$onStateChange$$inlined$runInConnectionScope$1.invoke(Unknown Source:8)
at io.ktor.client.webrtc.AndroidWebRtcDataChannel$setupEvents$1$onStateChange$$inlined$runInConnectionScope$1.invoke(Unknown Source:4)
at kotlinx.coroutines.intrinsics.UndispatchedKt.startCoroutineUndispatched(Undispatched.kt:20)
at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:360)
at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:134)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch(Builders.common.kt:209)
at kotlinx.coroutines.BuildersKt.launch(Unknown Source:1)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch$default(Builders.common.kt:200)
at kotlinx.coroutines.BuildersKt.launch$default(Unknown Source:1)
at io.ktor.client.webrtc.AndroidWebRtcDataChannel$setupEvents$1.onStateChange(DataChannel.kt:139)
Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [CoroutineName(Peer-A-Scope), StandaloneCoroutine{Cancelling}@8fcbbbb, Dispatchers.Default]
I attached a demo project that showcases the issue:
git clone demo.bundle- Run the app on Android (I think other platforms are also affected, but I tested on Android)
- Click the button "Setup Call"
- Click the button "Disconnect PeerB"
- Observe how PeerA crashes with the above stacktrace
HttpCache: InvalidCacheStateException is thrown on 304 when no cached entry matches varyKeys
Summary:
On a 304 Not Modified, HttpCache throws InvalidCacheStateException whenever findAndRefresh returns null — i.e. when no cached entry's varyKeys match the response's varyKeys. KTOR-8345 / PR #4816 ("Make Vary Header check lenient") only softened the case where an entry is found but its Vary size differs (it now logs a warning and proceeds). It did not touch the null branch, which still throws unconditionally.
Where:
ktor-client-core → io.ktor.client.plugins.cache.HttpCache, receive interceptor:
if (response.status == HttpStatusCode.NotModified) {
val responseFromCache =
plugin.findAndRefresh(response.call.request, response)
?: throw InvalidCacheStateException(response.call.request.url) // ← this line
if (responseFromCache.varyKeys().size != response.varyKeys().size) {
LOGGER.warn("... Falling back to missing cache logic ...") // ← #4816, only reached if non-null
}
...
}
How it happens in practice:
A very common trigger is a server-side CORS change that adds Vary: Origin:
- A response is cached while it has no Vary header → stored with varyKeys = {}.
- The backend later starts sending Vary: Origin (CORS). On revalidation the 304 carries Vary: Origin, so response.varyKeys() = {"Origin": ""} (native client sends no Origin).
- storage.find(url, {"Origin": ""}) finds nothing (stored entry has {}) → findAndRefresh returns null → throws.
Critically, the throw happens before the entry can be refreshed, so the bad entry is never replaced — the client stays permanently stuck on that URL until the cache is cleared.
Minimal reproduction (MockEngine):
val etag = "\"v1\""
val engine = MockEngine { request ->
if (request.headers.contains(HttpHeaders.IfNoneMatch)) {
respond("", HttpStatusCode.NotModified, headersOf(
HttpHeaders.ETag to listOf(etag),
// vary added here
HttpHeaders.Vary to listOf("Origin"),
))
} else {
respond("body", HttpStatusCode.OK, headersOf(
HttpHeaders.ETag to listOf(etag),
HttpHeaders.CacheControl to listOf("max-age=0, must-revalidate"), // force revalidation
// note: NO Vary on the 200
))
}
}
val client = HttpClient(engine) { install(HttpCache) }
client.get("https://example.com/x").bodyAsText() // caches with varyKeys = {}
// Throws
client.get("https://example.com/x").bodyAsText() // 304 carries Vary: Origin → InvalidCacheStateException
Suggested fix:
Apply the same leniency to the null branch: on findAndRefresh == null, fall back to a fresh network fetch (and replace the stale entry) instead of throwing. The current LOGGER.warn even references "Falling back to missing cache logic," but the code doesn't actually do so.
ContentNegotiation: The charset of `Accept-Charset` header is used for response deserialization
The client ContentNegotiation plugin decodes the response body using the charset derived from the outgoing request headers (Accept-Charset) instead of the charset declared in the response's Content-Type header. This causes incorrect decoding (Mojibake or parse errors) for any server that responds with a non-UTF-8 charset, such as Content-Type: application/xml; charset=utf-16.
In ContentNegotiation.kt, the transformResponseBody block reads the charset from the request headers:
transformResponseBody { response, body, info ->
val contentType = response.contentType() ?: return@transformResponseBody null
val charset = response.request.headers.suitableCharset()
convertResponse(response.request.url, info, body, contentType, charset)
}
suitableCharset() is defined as:
/**
* Detect suitable charset for an application call by `Accept` header or fallback to [defaultCharset]
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.serialization.suitableCharset)
*/
public fun Headers.suitableCharset(defaultCharset: Charset = Charsets.UTF_8): Charset =
suitableCharsetOrNull(defaultCharset) ?: defaultCharset
/**
* Detect suitable charset for an application call by `Accept` header or fallback to null
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.serialization.suitableCharsetOrNull)
*/
public fun Headers.suitableCharsetOrNull(defaultCharset: Charset = Charsets.UTF_8): Charset? {
@Suppress("DEPRECATION")
for ((charset, _) in parseAndSortHeader(get(HttpHeaders.AcceptCharset))) {
when {
charset == "*" -> return defaultCharset
Charsets.isSupported(charset) -> return Charsets.forName(charset)
}
}
return null
}
It reads Accept-Charset from the request headers, this always falls back to UTF-8, completely ignoring the charset the server declared in the response.
I wrote a temporary patch which fixes it by reading the charset from contentType:
transformResponseBody { response, body, info ->
val contentType = response.contentType() ?: return@transformResponseBody null
val charset = contentType.parameters.suitableCharset()
convertResponse(response.request.url, info, body, contentType, charset)
}
private fun List<HeaderValueParam>.suitableCharset(defaultCharset: Charset = Charsets.UTF_8): Charset =
suitableCharsetOrNull() ?: defaultCharset
private fun List<HeaderValueParam>.suitableCharsetOrNull(): Charset? =
find { it.name == "charset" }?.let { charsetParam ->
val charsetName = charsetParam.value
if (Charsets.isSupported(charsetName)) {
Charsets.forName(charsetName)
} else {
LOGGER.warn("Unsupported charset '$charsetName' in content type")
null
}
}
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
HttpRequestBuilder.timeout call removes capabilities set in DefaultRequest
Bug found in Ktor 3.4.3: setting a specific call timeout on http post through unixSocket magically turn it in a request to http://127.0.0.1.
If you put "magicTransform=false" the bug disappears and the function correctly foward the request to engine.sock.
suspend fun test1()
{
val magicTransform=trueHttpClient(CIO) {
expectSuccess = true
install(HttpTimeout) {
connectTimeoutMillis = 5000
}
defaultRequest {
unixSocket("engine.sock")
}
}.preparePost("/test") {
if(magicTransform) {
timeout {
requestTimeoutMillis = 86400000
socketTimeoutMillis = 20000
}
}
setBody("test")
}.execute()
}
Darwin: WebSocket client crashes the process when a PONG arrives after the session is closed
Summary
On the Darwin client engine, DarwinWebsocketSession.receiveFrame re-throws the channel close cause when _incoming is already closed. receiveFrame only runs inside the sendPingWithPongReceiveHandler block, which NSURLSession invokes on its delegate NSOperationQueue. A Kotlin throw there escapes into an Objective-C frame with no Kotlin handler to unwind into, so Kotlin/Native callsstd::terminate → abort() — the whole process crashes (SIGABRT).
Triggered by: WebSockets installed with a pingInterval, and the session cancelled with a cause while a ping/pong is in flight.
Affected versions
Reproduced on 3.4.3 and 3.5.0 (code is identical). Only the Darwin engine.
Crash signature
abort → std::__terminate → ThrowException
DarwinWebsocketSession.receiveFrame
DarwinWebsocketSession.sendMessages (pong handler)
__NSBLOCKOPERATION_IS_CALLING_OUT_TO_A_BLOCK__
Root cause
ktor-client-darwin/.../internal/DarwinWebsocketSession.kt:
private fun receiveFrame(frame: Frame) {
val result = _incoming.trySend(frame)
when {
result.isSuccess -> return
result.isClosed -> result.exceptionOrNull()?.let { throw it } // crashes inside the ObjC block
else -> launch(start = CoroutineStart.UNDISPATCHED) { _incoming.send(frame) }
}
}
When socketJob completes it closes _incoming (see init). A pong landing after that hits isClosed, and throw it runs on the Foundation callback thread.
Suggested fix
The session is already closing and the close cause is already delivered to consumers of incoming, so the late frame can simply be dropped instead of thrown:
private fun receiveFrame(frame: Frame) {
val result = _incoming.trySend(frame)
when {
result.isSuccess -> return
result.isClosed -> return // session closing; never throw from this ObjC callback
else -> launch(start = CoroutineStart.UNDISPATCHED) { _incoming.send(frame) }
}
}
(The else branch is safe: its throw is captured by the coroutine, not propagated to the Objective-C caller.) Verified: compiles against 3.5.1-SNAPSHOT.
Workaround
Don't set WebSockets.pingInterval on Darwin (iOS/macOS) targets.
Darwin: findCharset("UTF-16") maps to NSUTF16LittleEndianStringEncoding causing decoding failure for UTF-16 content with BOM
On Darwin platforms (iOS/macOS), decoding a UTF-16 byte stream that contains a BOM produces completely garbled output. Every single character is decoded incorrectly, rather than just the BOM character itself. This is because findCharset("UTF-16") is hardcoded to return Charsets.UTF_16, which is internally constructed as CharsetDarwin(platformUtf16) = CharsetDarwin("UTF-16LE"), which maps to NSUTF16LittleEndianStringEncoding. This encoding does not process BOM and does not auto-detect byte order — it always treats the bytes as little-endian raw data.
In CharsetNative.kt , platformUtf16 is defined as:
internal val platformUtf16: String =
if (ByteOrder.nativeOrder() == io.ktor.utils.io.core.ByteOrder.BIG_ENDIAN) "UTF-16BE" else "UTF-16LE"
And in CharsetDarwin.kt:
public actual object Charsets {
...
internal val UTF_16: Charset = CharsetDarwin(platformUtf16)
}
internal actual fun findCharset(name: String): Charset {
if (name == "UTF-8" || name == "utf-8" || name == "UTF8" || name == "utf8") return Charsets.UTF_8
if (name == "ISO-8859-1" || name == "iso-8859-1" || name == "ISO_8859_1") return Charsets.ISO_8859_1
if (name == "UTF-16" || name == "utf-16" || name == "UTF16" || name == "utf16") return Charsets.UTF_16
return CharsetDarwin(name)
}
private class CharsetDarwin(name: String) : Charset(name) {
@OptIn(UnsafeNumber::class)
val encoding: NSStringEncoding = when (name.uppercase()) {
"UTF-8" -> NSUTF8StringEncoding
"ISO-8859-1" -> NSISOLatin1StringEncoding
"UTF-16" -> NSUTF16StringEncoding
"UTF-16BE" -> NSUTF16BigEndianStringEncoding
"UTF-16LE" -> NSUTF16LittleEndianStringEncoding
"UTF-32" -> NSUTF32StringEncoding
"UTF-32BE" -> NSUTF32BigEndianStringEncoding
"UTF-32LE" -> NSUTF32LittleEndianStringEncoding
"ASCII" -> NSASCIIStringEncoding
"NEXTSTEP" -> NSNEXTSTEPStringEncoding
"JAPANESE_EUC" -> NSJapaneseEUCStringEncoding
"LATIN1" -> NSISOLatin1StringEncoding
else -> throw IllegalArgumentException("Charset $name is not supported by darwin.")
}
override fun newEncoder(): CharsetEncoder = object : CharsetEncoder(this) {}
override fun newDecoder(): CharsetDecoder = object : CharsetDecoder(this) {}
}
The interception in findCharset causes "UTF-16" to be resolved as CharsetDarwin("UTF-16LE"), whose name field is "UTF-16LE". This means the when block in CharsetDarwin selects NSUTF16LittleEndianStringEncoding instead of the BOM-aware NSUTF16StringEncoding.
And Charsets.isSupported is inconsistent with CharsetDarwin's actual support
Charsets.isSupported() on posix/Darwin (defined in CharsetNative.kt) only recognizes a small hardcoded set of names:
public fun isSupported(charset: String): Boolean = when (charset) {
"UTF-8", "utf-8", "UTF8", "utf8" -> true
"ISO-8859-1", "iso-8859-1" -> true
"UTF-16", "utf-16", "UTF16", "utf16" -> true
else -> false
}
However, CharsetDarwin (the actual Darwin implementation) fully supports a much wider set of charsets:
val encoding: NSStringEncoding = when (name.uppercase()) {
"UTF-8" -> NSUTF8StringEncoding
"ISO-8859-1" -> NSISOLatin1StringEncoding
"UTF-16" -> NSUTF16StringEncoding
"UTF-16BE" -> NSUTF16BigEndianStringEncoding
"UTF-16LE" -> NSUTF16LittleEndianStringEncoding
"UTF-32" -> NSUTF32StringEncoding
"UTF-32BE" -> NSUTF32BigEndianStringEncoding
"UTF-32LE" -> NSUTF32LittleEndianStringEncoding
"ASCII" -> NSASCIIStringEncoding
"NEXTSTEP" -> NSNEXTSTEPStringEncoding
"JAPANESE_EUC" -> NSJapaneseEUCStringEncoding
"LATIN1" -> NSISOLatin1StringEncoding
else -> throw IllegalArgumentException("Charset $name is not supported by darwin.")
}
WebRTC statistics fetching race condition
WebRTC statistics fetching starts before the connection is initialised, which can lead to an NPE if the delay is faster than the connection initialisation.
Crash on Android 7: NoSuchMethodError getInstanceStrong() since 3.5.0
After commit #dc8159e (Improve crypto performance) ktor 3.5.0 crashes on Android 7 because of using SecureRandom.getInstanceStrong() that is available only from API 26 (Android 8.0).
Ktor supports Android 1.x+ for Android engine, 5.0+ for OkHttp and 7.0+ for CIO, according to your documentation.
Full exception:
java.lang.NoSuchMethodError: No static method getInstanceStrong()Ljava/security/SecureRandom; in class Ljava/security/SecureRandom; or its super classes (declaration of 'java.security.SecureRandom' appears in /system/framework/core-oj.jar)
at io.ktor.util.NonceKt.lookupSecureRandom(Nonce.kt:137)
at io.ktor.util.NonceKt.access$lookupSecureRandom(Nonce.kt:1)
at io.ktor.util.NonceKt$nonceGeneratorJob$1.invokeSuspend(Nonce.kt:50)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:586)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:807)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:717)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:704)
Digest Auth: The plugin sends incorrect nonce when server responds with multiple WWW-Authenticate headers
To reproduce, execute the following test:
@Test
fun test() = testApplication {
application {
routing {
get("/") {
val authorization = call.request.header(HttpHeaders.Authorization)
if (authorization != null) {
call.respondText(authorization)
} else {
call.response.header(HttpHeaders.WWWAuthenticate, "Digest realm=\"Access to the '/' path\", nonce=\"742c60c1e89c86c32f14506ead7c4a7d\", algorithm=SHA-512-256, charset=UTF-8, qop=\"auth\"")
call.response.header(HttpHeaders.WWWAuthenticate, "Digest realm=\"Access to the '/' path\", nonce=\"bd4be7f3c89b5abd25d173d8fffb0a9a\", algorithm=MD5, charset=UTF-8, qop=\"auth\"")
call.respond(HttpStatusCode.Unauthorized)
}
}
}
}
val client = createClient {
install(Auth) {
digest {
credentials {
DigestAuthCredentials(username = "jetbrains", password = "foobar")
}
realm = "Access to the '/' path"
}
}
}
val response = client.get("/")
val auth = response.call.request.headers[HttpHeaders.Authorization]
assertNotNull(auth)
val nonce = Regex("""nonce="([^"]+)"""").find(auth)!!.groupValues[1]
assertEquals("bd4be7f3c89b5abd25d173d8fffb0a9a", nonce)
}
The client should send the nonce for the default MD5 algorithm, but sends the first one.
CIO: The engine imports `node:net` statically and breaks wasmJsBrowser webpack build
Description
io.ktor:ktor-client-cio:3.5.0 breaks a Kotlin/Wasm browser build when bundled with Webpack.
The same project works with io.ktor:ktor-client-cio:3.4.1.
The failure seems related to the change introduced by KTOR-7659, where the Node.js network module is now imported as a static ESM import:
import * as bm9kZTpuZXQ from 'node:net';
In 3.4.1, the generated code used a dynamic require instead:
'io.ktor.network.sockets.nodejs.nodeNet' : () => eval('require')('node:net'),
With 3.5.0, Webpack sees the static node:net import during bundling and fails in a browser/WasmJs target.
Reproducer
Use a Kotlin Multiplatform project targeting wasmJsBrowser with ktor-client-cio.
Minimal dependency setup:
kotlin {
wasmJs {
browser {}
}
}
sourceSets {
commonMain {
dependencies {
implementation("io.ktor:ktor-client-cio:3.5.0")
implementation("io.ktor:ktor-client-content-negotiation:3.5.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.5.0")
}
}
}
Then run the browser development task, for example:
./gradlew wasmJsBrowserDevelopmentRun
Actual behavior
Webpack fails with:
Module build failed: UnhandledSchemeError: Reading from "node:net" is not handled by plugins (Unhandled scheme).
Expected behavior
ktor-client-cio is documented as available on WasmJs/browser targets, so using ktor-client-cio:3.5.0 should not introduce a static Node.js-only import that breaks Webpack browser bundling.
The browser/WasmJs bundle should either:
- not include the Node.js-specific
node:netimport, or - keep it hidden behind a platform/runtime-specific branch that does not get resolved by browser bundlers, or
- provide a browser-compatible variant that does not statically import Node.js core modules.
Regression
This appears to be a regression from 3.4.1 to 3.5.0.
Confirmed behavior:
io.ktor:ktor-client-cio:3.4.1works.io.ktor:ktor-client-cio:3.5.0fails.- Forcing only
ktor-client-cioback to3.4.1and keeping all other ktor libraries at version3.5.0makes the project build again.
WebRTC: Peer stops receiving messages after a while on iOS
To reproduce with an attached sample project:
- Ensure Ruby 3.* is installed
- Execute
gem install cocoapods - Specify CocoaPods executable path:
echo -e "kotlin.apple.cocoapods.bin=$(which pod)" >> local.properties - Execute the
:composeApp:podspecGradle task - Install pods:
cd iosApp && pod install - Run the iOS app
- Click the
Setup Callbutton - After a few messages, DataChannel::receive stops receiving new messages. To observe the bug without waiting, one can enable
Debug->Simulate memory warnings.
I cannot reproduce on Android.
Apache5: The underlying request is not aborted when coroutine Job is cancelled
After HttpResponseData is returned, the Apache future is the owner of the streaming response. But if responseChannel / call context is cancelled later, nothing cancels that future.
We have a test demonstrating this problem: ServerSentEventsTest.testCancellingUnderlyingConnection.
sseSession.cancel()cancels Ktor-side session/input/call context.- Apache5 response consumer’s channel is closed/cancelled.
- But Apache’s underlying streaming request remains alive.
- Server keeps
/sse/active-sessionsopen. /active-sessions-countstays 1.- Test times out.
Add more details for KDoc of BearerAuthConfig.sendWithoutRequest
Api symbol: io.ktor.client.plugins.auth.providers.BearerAuthConfig.sendWithoutRequest:
The KDoc is not very descriptive.
Add KDoc for BearerAuthConfig.realm
Api symbol: io.ktor.client.plugins.auth.providers.BearerAuthConfig.realm:
Zero info about whether it's needed, its purpose, and for those purposes, what the typical values should be.
Add Kdoc for BearerAuthConfig.refreshTokens
Api symbol: io.ktor.client.plugins.auth.providers.BearerAuthConfig.refreshTokens:
Lack of info on concurrency-safety, and what happens if null is returned in the lambda.
Compiler Plugin
Fix Kotlin 2.4.0 compiler plugin breaking changes
OpenAPI plugin: handle requireXxx functions in compiler plugin
Core
GMTDate() being offset by 369 years on Windows
When cross-compiling a Native project from Linux to mingw, calling GMTDate() gives a date that's 369 years into the future, ex.:
fun main() {
println(GMTDate())
}
// ->
GMTDate(seconds=47, minutes=31, hours=20, dayOfWeek=TUESDAY, dayOfMonth=19, dayOfYear=199, month=JULY, year=2394, timestamp=13397430707076)
This isn't an issue when compiling to a Linux target. I haven't tried compiling to mingw on a Windows machine.
Duplicate PROPERTY_SETTER target in @InternalAPI annotation
AnnotationTarget.PROPERTY_SETTER appears twice in the @Target of @InternalAPI (ktor-io/common/src/io/ktor/utils/io/Annotations.kt:25-26)
/**
* API marked with this annotation is internal, and it is not intended to be used outside Ktor.
* It could be modified or removed without any notice. Using it outside Ktor could cause undefined behaviour and/or
* any unexpected effects.
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.utils.io.InternalAPI)
*/
@RequiresOptIn(
level = RequiresOptIn.Level.ERROR,
message = "This API is internal in Ktor and should not be used. It could be removed or changed without notice."
)
@Target(
AnnotationTarget.CLASS,
AnnotationTarget.TYPEALIAS,
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY,
AnnotationTarget.FIELD,
AnnotationTarget.CONSTRUCTOR,
AnnotationTarget.PROPERTY_SETTER,
AnnotationTarget.PROPERTY_SETTER
)
@Retention(AnnotationRetention.BINARY)
public annotation class InternalAPI
I couldn't find any usage of @InternalAPI on a getter; so it seems that it is just duplicated rather than a typo.
Docs
Update the log4j bridge version in Logging
They say about Logging (version 3.4.3):
There is a version 2 of the jog4j bridge `log4j-slf4j2-impl` (https://logging.apache.org/log4j/2.x/manual/installation.html#impl-core-bridge-slf4j).
Please update the docs
Gradle Plugin
Gradle plugin environment variable is not included in the image
Currently, the Ktor gradle plugin includes an option for environment variables, but they're only included when running via the runDocker task. These should be included in the image configuration so that they will be present when the image is executed externally (deployed)
IO
OutputStreamContent and WriterContent can exhaust Dispatchers.IO
OutputStreamContent calls channel.toOutputStream().use { ... } on Dispatchers.IO. When combined with ByteWriteChannel.onClose { job.join() } which reader applies also on IO, it can lead to Dispatchers.IO exhaustion if 64 (thread limit) parallel requests are in flight.
Sample test:
// Limit parallelism to 1 thread to make it easier to exhaust the dispatcher
private val singleThreadDispatcher = Dispatchers.IO.limitedParallelism(1)
@Test
fun `toOutputStream in combination with reader should not exhaust dispatcher`() = runTest {
val readerStarted = CompletableDeferred<Unit>()
val channel = reader(singleThreadDispatcher + CoroutineName("reader")) {
readerStarted.complete(Unit)
val buffer = ByteArray(1)
while (channel.readAvailable(buffer) != -1) {
// consume until close
}
}.channel
readerStarted.await()
launch(singleThreadDispatcher + CoroutineName("writer")) {
channel.use {
channel.toOutputStream().use { stream ->
stream.write(42)
}
}
}
}
RawSourceChannel: coroutine cancellation is not propagated to the RawSource
The channel returned by InputStream.toByteReadChannel() might not cancel underlying InputStream, when coroutine is cancelled.
Observed behavior: After cancelling the coroutine that's called InputStream.toByteReadChannel(job), the channel's closedCause might remain null.
Expected behavior: The underlying source should be cancelled when the coroutine Job is cancelled and closedCause should be set.
Reproduction: A shared integration test testBodyChannelCancelledWhenCallerScopeIsCancelled in HttpStatementTest is flaky on the Android engine:
▶ Run with CIO ✓ PASSED (143ms)
▶ Run with Apache5 ✓ PASSED (120ms)
▶ Run with Android
[1/2] • FAILED (2.030872600s)
└─ Timed out after 2s waiting for body to be closed
[2/2] ✕ FAILED (2.032592786s)
└─ Timed out after 2s waiting for body to be closed
▶ Run with OkHttp ✓ PASSED (145ms)
▶ Run with Java ✓ PASSED (107ms)
Server
RateLimit plugin is bypassed when the nested authenticate block rejects the request
Hello everyone,
As per this discussion, the order for interceptors seems to break rate-limiting functionality when used together with the authentication plugin, because the auth interceptor executes after the rate-limiting one.
This means that any endpoint that looks like this:
rateLimit(RateLimitName("MyRateLimit")) {
authenticate("my-auth-configuration") {
post("/endpoint") {
call.respond()
}
}
}
Will instinctively look like it will work as you would expect from the structure: Rate limiting executes first, and only if it passes rate limiting will the authentication be executed.
With the above structure, the rate limiter does not get executed at all, because in RateLimitInterceptors.kt, the guard clause in line 46 hinders rate limiting execution.
If you imagine a login endpoint behind that, no rate limiting would allow infinite login attempts while the code looks completely fine from the outside.
Compression: The plugin ignores Accept-Encoding q=0 and most-specific matching
The server-side Compression plugin chooses a response content-coding by sorting all Accept-Encoding entries by quality and picking the best. It never excludes a coding the client refused with q=0, never lets an explicitly named coding override a * wildcard, and matches coding tokens case-sensitively. This contradicts HTTP content negotiation (RFC 7231 §5.3.4).
Type: Bug
Steps to reproduce:
- install(Compression) { gzip(); deflate() }
- GET / with Accept-Encoding: gzip;q=0
Expected: response is not gzip-compressed (gzip refused via q=0).
Actual: response is gzip-compressed.
More cases:
*;q=0 still compresses; gzip;q=0.1, *;q=0.9 picks gzip instead of deflate; GZIP;q=0 still uses gzip (case-sensitive).
Affected version: 3.x. Platform: JVM (ktor-server-compression).
DI: The close method is called twice on cleanup of AutoCloseable
When using an object that implements the `AutoCloseable` interface in the Ktor DI, the `close()` method is called multiple times when the application is terminated. Is this intentional behavior? (ktor version 3.2.3)
The interface specification recommends implementing idempotency. but I think it needs to be improved so that it is called only once in the service logic.
class SampleService: AutoCloseable {
var count = 0
override fun close() {
count++
println("cleanup, count: $count")
}
}
@Test
fun applicationTest() = testApplication {
application {
dependencies {
provide<SampleService> { SampleService() }
}
dependencies.resolve<SampleService>()
}
}
/* console output
cleanup, count: 1
cleanup, count: 2
*/
{width=70%}
CORS plugin drops OPTIONS preflight requests when allowSameOrigin is on
When using the CORS plugin, if an OPTIONS request is sent where the origin and host header match, and the CORS option allowSameOrigin is set to true, the OPTIONS request is then passed through to the route handler. This results in a 405 Method Not Allowed response.
However, if allowSameOrigin is set to false, then the CORS plugin handles the request correctly, and returns a 200 response.
I've created a reproducible example service here: https://github.com/Skater901/ktor-cors-same-origin-example
From debugging the code, I believe the difference is due to these two branches in the when statement: https://github.com/ktorio/ktor/blob/main/ktor-server/ktor-server-plugins/ktor-server-cors/common/src/io/ktor/server/plugins/cors/CORS.kt#L114-L117
If allowSameOrigin is true, and the host and origin headers match, then the result is SkipCORS, which returns from the method without setting a response. This allows the pipeline to continue processing, but means that a 405 response will be returned, when no OPTIONS handler is found for the specified route.
I think that this either needs to be explicitly documented, or SkipCORS needs to be handled the same way as OK, where the plugin continues the CORS processing and returns a CORS response.
CORS is skipped when the Origin header contains an IPv6 address
Description
Ktor CORS appears to reject a browser-valid IPv6 literal origin before applying CORS host matching.
When a request contains this Origin header:
Origin: http://[::1]:22222
Ktor logs:
TRACE io.ktor.server.plugins.cors.CORS - GET /: Start handler
TRACE io.ktor.server.plugins.cors.CORS - GET /: Skip CORS handler because Origin http://[::1]:22222 is malformed
As a result, the response does not include Access-Control-Allow-Origin, even when CORS is configured with anyHost().
The same application behaves correctly when using a hostname origin:
Origin: http://localhost:22222
In that case Ktor returns:
Access-Control-Allow-Origin: *
Minimal reproducer
fun main() {
embeddedServer(
factory = io.ktor.server.netty.Netty,
port = 8080,
host = "0.0.0.0",
module = {
install(CORS) {
anyHost()
}
routing {
get("/") {
call.respondText("Hello, World!")
}
}
}
).start(wait = true)
}
Full MRE on GitHub Gist
Reproduction steps
Run the server from the MRE, then execute:
curl -i -H 'Origin: http://localhost:22222' http://localhost:8080/
Actual result:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Content-Length: 13
Content-Type: text/plain; charset=UTF-8
Hello, World!
Then execute:
curl -i -H 'Origin: http://[::1]:22222' http://localhost:8080/
Actual result:
HTTP/1.1 200 OK
Content-Length: 13
Content-Type: text/plain; charset=UTF-8
Hello, World!
Application log:
TRACE io.ktor.server.plugins.cors.CORS - GET /: Start handler
TRACE io.ktor.server.plugins.cors.CORS - GET /: Skip CORS handler because Origin http://[::1]:22222 is malformed
Expected behavior
http://[::1]:22222 should not be treated as a malformed origin.
With anyHost() enabled, Ktor should accept this Origin and return the expected CORS response header, for example:
Access-Control-Allow-Origin: *
Why this seems valid
Browsers can serialize IPv6 literal hosts in origins using square brackets, for example:
http://[::1]:22222
Firefox sends this Origin header when a frontend is opened from:
http://[::1]:22222/
So this appears to be a valid browser-generated origin, not a malformed client request.
See also: Origin Header | MDN
Possible cause
The issue seems to come from Ktor's origin validation logic in isValidOrigin:
Current logic scans the origin starting after :// and treats the first : or / as the beginning of the port/trailing part:
var portIndex = origin.length
for (index in protoDelimiter + 3 until origin.length) {
val ch = origin[index]
if (ch == ':' || ch == '/') {
portIndex = index + 1
break
}
if (ch == '?') return false
}
That works for origins like:
http://localhost:22222
but not for bracketed IPv6 literals like:
http://[::1]:22222
For http://[::1]:22222, the first : after :// is part of the IPv6 address, not the port separator:
http://[::1]:22222
^
The validator then appears to treat the rest of the IPv6 host as a port-like suffix, reaches non-digit characters, and rejects the origin as malformed.
In other words, the validation logic does not appear to account for bracketed IPv6 host syntax before applying port parsing.
Environment
From the MRE:
kotlin("jvm") version "2.4.0"
id("io.ktor.plugin") version "3.5.0"
Dependencies:
implementation("io.ktor:ktor-server-core")
implementation("io.ktor:ktor-server-cors")
implementation("io.ktor:ktor-server-netty")
implementation("ch.qos.logback:logback-classic:1.5.34")
JVM toolchain:
jvmToolchain(21)
Notes
This affects local development setups where a frontend dev server is opened via IPv6 loopback, for example:
http://[::1]:22222/
A workaround is to use localhost or 127.0.0.1 instead, but Ktor should probably accept this valid browser-generated IPv6 literal origin.
KotlinxSerializationConverter: it fails to deserialize an empty channel closed with a delay
Provided by me:
When route calls receiveNullable<T?>() with Content-Type: application/json and an empty body, the server sometimes returns 400 Bad Request and sometimes the 200 Success, and it depends on the network speed. You can find the reproducer in attachment. Test that is written there is failing with 400, but commenting Thread.sleep(200) part changes the service response to 200
Provided by agent:
This is a race condition introduced by how RequestBodyLimit.applyLimit wraps the incoming channel.
Root Cause
RequestBodyLimit.applyLimit always wraps the incoming ByteReadChannel via GlobalScope.writer { ... }.channel:
@OptIn(DelicateCoroutinesApi::class)
internal fun ByteReadChannel.applyLimit(limit: Long): ByteReadChannel =
GlobalScope.writer {
// ...
while (!isClosedForRead) {
val read = readAvailable(array, 0, array.size)
// ...
}
}.channel
The returned channel is not immediately isClosedForRead = true — the writer coroutine must be scheduled and run first. When the body data is not yet available, the writer suspends on readAvailable. At this point, ContentNegotiation's RequestConverter checks isClosedForRead on the wrapper channel:
• If the writer has already run and closed the channel → true → body is treated as empty → receiveNullable returns null ✓
• If the writer hasn't run yet → false → KotlinxSerializationConverter calls readRemaining(), gets "", calls Json.decodeFromString("") → JsonDecodingException → 400 ✗
The race window is opened by any timing gap between the request headers arriving and the empty body terminator arriving — which is the normal case over a real network connection.
Expected Behavior
receiveNullable<T?>() should return null for an empty body regardless of whether RequestBodyLimit is installed and regardless of network timing.
Possible Fixes
- In applyLimit: If the source channel is already isClosedForRead at wrap time, return an already-closed channel instead of launching a writer.
- In KotlinxSerializationConverter: Treat an empty body as null for nullable reified types rather than attempting decodeFromString("").
- In ContentNegotiation's RequestConverter: After readRemaining() returns an empty string, check nullability before delegating to the converter.
Option 1 is the most targeted fix and avoids the unnecessary GlobalScope.writer allocation for the common empty-body case.
OpenAPI: `@JsonClassDiscriminator` on sealed types is ignored when generating OpenAPI discriminator schema
Description
When a sealed interface or sealed class is annotated with @JsonClassDiscriminator("kind"), the generated OpenAPI schema always uses "type" as the discriminator.propertyName, ignoring the annotation value entirely.
This produces a mismatch between the actual JSON serialization (which correctly uses "kind" as the discriminator key) and the OpenAPI specification (which declares "type").
Steps to Reproduce
@JsonClassDiscriminator("kind")
@Serializable
sealed interface Shape {
@Serializable
@SerialName("circle")
data class Circle(val radius: Double) : Shape
@Serializable
@SerialName("rectangle")
data class Rectangle(val width: Double, val height: Double) : Shape
}
Expose this type as an OpenAPI response body. Inspect the generated schema.
Expected behavior
discriminator:
propertyName: kind # value from @JsonClassDiscriminator("kind")
Actual behavior
discriminator:
propertyName: type # hardcoded, annotation is not read
Root Cause
In KotlinxSerializerJsonSchemaInference.buildSchemaFromDescriptor (JsonSchemaInference.kt), the PolymorphicKind.SEALED branch reads the discriminator property name as:
val discriminatorProperty = descriptor.getElementName(0)
For a polymorphic descriptor, getElementName(0) always returns "type" regardless of any @JsonClassDiscriminator annotation on the class. Although @JsonClassDiscriminator is a @SerialInfo annotation and therefore present in descriptor.annotations, it is never consulted.
The same issue exists in ReflectionJsonSchemaInference (JsonSchemaInference.jvm.kt), where "type" is hardcoded unconditionally.
Suggested Fix
// KotlinxSerializerJsonSchemaInference
val discriminatorProperty = descriptor.annotations
.filterIsInstance<JsonClassDiscriminator>()
.firstOrNull()?.discriminator
?: descriptor.getElementName(0)
// ReflectionJsonSchemaInference
val discriminatorProperty = kClass.annotations
.filterIsInstance<JsonClassDiscriminator>()
.firstOrNull()?.discriminator
?: "type"
Affected Versions: 3.x (confirmed on 3.5.0)
SessionTransportTransformerEncrypt init block uses wrong IV size for AES-256 (regression of KTOR-661)
The init block of SessionTransportTransformerEncrypt calls ivGenerator(encryptionKeySize) instead of ivGenerator(blockSize). For AES-256, encryptionKeySize is 32 bytes, but AES CBC requires a 16-byte IV. This causes InvalidAlgorithmParameterException: Wrong IV length: must be 16 bytes long when constructing the transformer with a 32-byte key.
This is the same bug as KTOR-661, which was marked "Obsolete" rather than fixed. The bug is still present in Ktor 3.3.3.
Affected code
SessionTransportTransformerEncrypt.kt:
init {
encrypt(ivGenerator(encryptionKeySize), byteArrayOf()) // BUG: should be blockSize (16), not encryptionKeySize (32)
mac(byteArrayOf())
}
Reproduction
install(Sessions) {
cookie<MySession>("SESSION") {
// AES-256: 32-byte key (64 hex chars)
val encryptKey = SecretKeySpec(hex("0".repeat(64)), "AES")
val signKey = SecretKeySpec(hex("0".repeat(64)), "HmacSHA256")
transform(SessionTransportTransformerEncrypt(encryptKey, signKey))
// Throws: java.security.InvalidAlgorithmParameterException: Wrong IV length: must be 16 bytes long
}
}
Workaround
Provide a custom ivGenerator that always produces 16-byte IVs:
transform(SessionTransportTransformerEncrypt(
encryptionKeySpec = SecretKeySpec(hex(encryptKey), "AES"),
signKeySpec = SecretKeySpec(hex(signKey), "HmacSHA256"),
ivGenerator = { ByteArray(16).apply { SecureRandom().nextBytes(this) } },
))
Suggested fix
In the init block, use the cipher block size instead of the key size:
init {
val blockSize = Cipher.getInstance("$encryptAlgorithm/CBC/PKCS5Padding").blockSize
encrypt(ivGenerator(blockSize), byteArrayOf())
mac(byteArrayOf())
}
Environment
- Ktor version: 3.4.1
- JVM: Amazon Corretto 21
- Related: KTOR-661 (same bug, marked Obsolete)
RoutingBuilder.contentType does not use parameters when matching
The following test does not work because the ContentTypeHeaderRouteSelector uses the main value only, but not the optional/required parameters: https://github.com/ktorio/ktor/blob/c9712a38abad4d592701f4e375733813bf91640a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RouteSelector.kt#L654 and not it.params
(IMHO parseAndSortContentTypeHeader should return List<ContentType> and not List<HeaderValue>)
Use-case:
I want to use different actions based on a parameter, mostly SOAP defines the optional action parameter that defines the action: https://www.iana.org/assignments/media-types/application/soap+xml
@Test
fun bug() = testApplication {
application {
routing {
contentType(ContentType.parse("foo/bar; action=baz")) {
get {
call.respondText("BAZ")
}
}
contentType(ContentType.parse("foo/bar; action=acc")) {
get {
call.respondText("ACC")
}
}
}
}
val baz = client.get("") {
setBody("Unused")
contentType(ContentType.parse("foo/bar; action=baz"))
expectSuccess = true
}.bodyAsText()
assertEquals( "BAZ", baz)
val action = client.get("") {
setBody("Unused")
contentType(ContentType.parse("foo/bar; action=acc"))
expectSuccess = true
}.bodyAsText()
assertEquals( "ACC", action)
}
Module: server-core
OpenAPI: nullable @JvmInline value class loses nullability in generated schema
When a plain String? is used in a request/response DTO, Ktor OpenAPI generates the expected schema type:
{
"type": ["string", "null"]
}
However, when a nullable @JvmInline value class is used, the Kotlin type is still nullable but the generated OpenAPI schema loses the null type.
Minimal example:
@JvmInline
@Serializable
value class SessionToken(val value: String)
@Serializable
data class LoginResponse(
val token: SessionToken?
)
Expected schema:
{
"type": ["string", "null"]
}
Actual schema:
{
"type": "string"
}
This suggests that during schema generation, either:
- nullability is checked before unwrapping the value class and then not preserved, or
- the nullable information is not propagated correctly after unwrapping to the underlying type.
In other words, nullable value class<T> is not being treated as nullable T.
Expected behavior to verify:
String?should remain["string", "null"]SessionTokenshould remain"type": "string"SessionToken?should become["string", "null"]- the same should hold for value classes wrapping
Int - the behavior should be consistent for both request DTO schemas and response DTO schemas
Additional note:
The related but slightly different case where the value class itself is non-null but its underlying type is nullable should also behave consistently, for example:
@JvmInline
@Serializable
value class NullableSessionToken(val value: String?)
@Serializable
data class LoginResponse(
val token: NullableSessionToken
)
In that case, the generated schema should also preserve nullability of the underlying type.
OpenAPI: Schema name collisions because`@SerialName` of sealed subtypes is used as `components.schemas` key
Environment
- Ktor: current
mainbranch / latest snapshot - OpenAPI generation:
ktor-server-routing-openapi - Serialization:
kotlinx.serialization
Problem
When generating OpenAPI for sealed hierarchies, Ktor currently uses the sealed subtype @SerialName as the schema component name.
As a result, two different Kotlin types from different sealed hierarchies can collide in components.schemas if they reuse the same @SerialName.
This is problematic because @SerialName is part of the serialized wire format and should not also define OpenAPI component identity.
Example
@OptIn(ExperimentalSerializationApi::class)
@JsonClassDiscriminator("status")
@Serializable
sealed interface FirstResponse {
@Serializable
@SerialName("shared_case")
data class SharedCase(val value: String) : FirstResponse
}
@OptIn(ExperimentalSerializationApi::class)
@JsonClassDiscriminator("status")
@Serializable
sealed interface SecondResponse {
@Serializable
@SerialName("shared_case")
data class SharedCase(val count: Int) : SecondResponse
}
Actual behavior
@SerialName("shared_case")is used as the OpenAPI schema component name- distinct subtype schemas from different sealed hierarchies collide in
components.schemas - avoiding the collision would require changing
@SerialName, which also changes the API wire format
Expected behavior
@SerialNameshould continue to control the discriminator / serialized subtype identity- schema component names should remain type-based and distinct
- component names should use the same approach as regular classes, ideally FQDN-based names
For the example above, expected component keys would be something like:
your.package.FirstResponse.SharedCaseyour.package.SecondResponse.SharedCase
Why this matters
Schema component names and serialization wire names are separate concerns.
Using @SerialName for both causes avoidable conflicts:
- discriminator values may intentionally be reused across hierarchies
- OpenAPI schema names must still remain unique
- changing a wire name just to fix documentation is not acceptable
Suggested behavior
Please keep:
- discriminator mapping key =
@SerialName
Please change:
components.schemaskey and$reftarget = Kotlin type-based name, consistent with normal class handling- preferably FQDN-based, to avoid collisions
For example, discriminator mapping could still be:
mapping:
shared_case: "#/components/schemas/your.package.FirstResponse.SharedCase"
while the schema component name remains unique and stable.
Reproduction
I reproduced this locally with a regression test in ktor-server-routing-openapi using two sealed hierarchies with the same subtype @SerialName.
The test expectation is that both distinct subtype schemas should be preserved in components.schemas, but the current implementation does not keep them distinct.
File.readChannel: do not close a non-opened file and ignore closing exceptions
We are using File.readChannel to upload a file. In some cases, due a race condition, the file does not exist. It's fully expected that this function will throw an exception in such a case. But readChannel tries to close the file in an invokeOnCompletion block, but if the file does not exist then reading randomAccessFile will throw a FileNotFoundException and likely crash your app.
Firstly, it would be nice if it didn't close the file unless it was opened (now it will open it just to close it, even if it wasn't opened previously). Secondly, any errors in closing the file should be caught and ignored.
Zstd Compression: "Destination buffer is too small" exception for a particular sequence of bytes
Upgrading Ktor form 3.4.3 to 3.5.0 breaks the Zstd compression of larger ByteArrays
Caused by: com.github.luben.zstd.ZstdException: Destination buffer is too small
at com.github.luben.zstd.ZstdCompressCtx.compressDirectByteBuffer(ZstdCompressCtx.java:557)
at com.github.luben.zstd.ZstdCompressCtx.compress(ZstdCompressCtx.java:628)
at io.ktor.encoding.zstd.Zstd.encodeTo(Zstd.jvm.kt:110)
at io.ktor.encoding.zstd.Zstd.access$encodeTo(Zstd.jvm.kt:38)
at io.ktor.encoding.zstd.Zstd$encode$1.invokeSuspend(Zstd.jvm.kt:43)
at io.ktor.encoding.zstd.Zstd$encode$1.invoke(Zstd.jvm.kt)
at io.ktor.encoding.zstd.Zstd$encode$1.invoke(Zstd.jvm.kt)
at io.ktor.utils.io.ByteWriteChannelOperationsKt$writer$job$1.invokeSuspend(ByteWriteChannelOperations.kt:185)
... 6 more
A 4KB file works but 11KB already breaks on 3.5.0, but works just fine using 3.4.3
(Server side/JVM)
Digest Auth: server must respond with one WWW-Authenticate header for each supported algorithm
To reproduce, execute the following test:
fun Application.testModule() {
install(Authentication) {
val userPasswords: Map<String, String> = mapOf(
"jetbrains" to "foobar",
"admin" to "password"
)
fun computeHash(userName: String, realm: String, password: String, algorithm: DigestAlgorithm): ByteArray =
algorithm.toDigester().digest("$userName:$realm:$password".toByteArray(UTF_8))
digest("auth-digest") {
realm = "Ktor realm"
algorithms = listOf(DigestAlgorithm.SHA_512_256, DigestAlgorithm.MD5)
digestProvider { userName, realm, algorithm ->
userPasswords[userName]?.let { password ->
computeHash(userName, realm, password, algorithm)
}
}
validate { credentials ->
if (credentials.userName.isNotEmpty()) {
credentials.userName
} else {
null
}
}
}
}
routing {
authenticate("auth-digest") {
get("/") {
call.respondText("Hello, ${call.principal<String>()}!")
}
}
}
}
@Test
fun `WWWAuthenticate header is valid for multiple algorithms`() = testApplication {
application {
testModule()
}
val response = client.get("/")
assertEquals(2, response.headers.getAll(HttpHeaders.WWWAuthenticate)?.size)
}
Two WWW-Authenticate headers are expected for each specified algorithm, because one header value cannot be parsed unambiguously.
Digest Auth: "Unsupported charset in digest authentication header" for a charset name in lowercase
To reproduce, execute the following test:
fun Application.testModule() {
install(Authentication) {
val userPasswords: Map<String, String> = mapOf(
"jetbrains" to "foobar",
"admin" to "password"
)
fun computeHash(userName: String, realm: String, password: String, algorithm: DigestAlgorithm): ByteArray =
algorithm.toDigester().digest("$userName:$realm:$password".toByteArray(UTF_8))
digest("auth-digest") {
realm = "Ktor realm"
algorithms = listOf(DigestAlgorithm.SHA_512_256, DigestAlgorithm.MD5)
digestProvider { userName, realm, algorithm ->
userPasswords[userName]?.let { password ->
computeHash(userName, realm, password, algorithm)
}
}
validate { credentials ->
if (credentials.userName.isNotEmpty()) {
credentials.userName
} else {
null
}
}
}
}
routing {
authenticate("auth-digest") {
get("/") {
call.respondText("Hello, ${call.principal<String>()}!")
}
}
}
}
@Test
fun `server accepts lowercase charset`() = testApplication {
application {
testModule()
}
val challengeResponse = client.get("/")
assertEquals(HttpStatusCode.Unauthorized, challengeResponse.status)
val wwwAuth = challengeResponse.headers[HttpHeaders.WWWAuthenticate]!!
val nonce = Regex("""nonce="([^"]+)"""").find(wwwAuth)!!.groupValues[1]
val realm = "Ktor realm"
val username = "admin"
val password = "password"
val ha1 = md5Hex("$username:$realm:$password")
val ha2 = md5Hex("GET:/")
val response = md5Hex("$ha1:$nonce:$ha2")
val authed = client.get("/") {
header(
HttpHeaders.Authorization,
"""Digest username="$username", realm="$realm", nonce="$nonce", uri="/", algorithm=MD5, response="$response", charset=utf-8"""
)
}
assertEquals(HttpStatusCode.OK, authed.status)
}
As a result, the server responds with 500 status and throws the following exception:
java.lang.IllegalArgumentException: Unsupported charset in digest authentication header
at io.ktor.server.auth.DigestCredentialKt.toDigestCredential(DigestCredential.kt:233)
at io.ktor.server.auth.DigestAuthenticationProvider.onAuthenticate(DigestAuth.kt:63)
at io.ktor.server.auth.AuthenticationInterceptorsKt$AuthenticationInterceptors$2$2.invokeSuspend(AuthenticationInterceptors.kt:136)
at io.ktor.server.auth.AuthenticationInterceptorsKt$AuthenticationInterceptors$2$2.invoke(AuthenticationInterceptors.kt)
at io.ktor.server.auth.AuthenticationInterceptorsKt$AuthenticationInterceptors$2$2.invoke(AuthenticationInterceptors.kt)
at io.ktor.server.auth.AuthenticationHook$install$1.invokeSuspend(AuthenticationInterceptors.kt:29)
at io.ktor.server.auth.AuthenticationHook$install$1.invoke(AuthenticationInterceptors.kt)
at io.ktor.server.auth.AuthenticationHook$install$1.invoke(AuthenticationInterceptors.kt)
According to the spec, the lowercase charset names must be supported.
Shared
ContentEncoding: Incomplete gzip response causes client/server to hang indefinitely
It happens that a server returns malformed gzipped content; when this happens, the client hangs forever because the GZIP inflater gets stuck in an infinite loop, consuming 100% CPU.
HttpTimeout has no effect. Using withTimeout does allow the code to continue, but the thread doing the inflating will still be running burning CPU.
The loop it gets stuck in is in EnvodersJvm at
while (!inflater.needsInput() && !inflater.finished()) {
totalSize += inflater.inflateTo(channel, writeBuffer, checksum)
readBuffer.position(readBuffer.limit() - inflater.remaining)
}
Here is a test to reproduce the issue:
val malformedGzip = byteArrayOf(
0x1f, 0x8b.toByte(), // Magic
0x08, // Deflate method
0x00, // Flags
0x00, 0x00, 0x00, 0x00, // Timestamp
0x00, // Extra flags
0xff.toByte(), // OS
// Incomplete deflate stream that causes Inflater to wait forever
0x01, 0x00, 0x00
)
test("malformed gzip hangs forever") {
val client = HttpClient(MockEngine) {
engine {
addHandler { request ->
respond(
content = malformedGzip,
status = HttpStatusCode.OK,
headers = headersOf(
HttpHeaders.ContentEncoding to listOf("gzip"),
HttpHeaders.ContentType to listOf("application/json")
)
)
}
}
install(ContentEncoding) {
gzip()
}
install(HttpTimeout) {
connectTimeoutMillis = 1000
socketTimeoutMillis = 1000
requestTimeoutMillis = 1000
}
}
val response = client.get("/test")
println(response.body<String>())
}
Other
Some issues related to digest authentication in 3.5.0
Bug Description
There are some issues related to digest authentication in Ktor 3.5.0:
- The Ktor server does not set the WWW-Authenticate header properly if multiple algorithms are specified.
- The Ktor server cannot handle the client response with the Authorization header containing "charset=utf-8", instead of "charset=UTF-8".
- The Ktor client may not use the correct nonce value if the server sends multiple WWW-Authenticate headers.
Reproduction Steps
Steps to reproduce the behavior:
- Run the example server: https://github.com/ktorio/ktor-documentation/tree/main/codeSnippets/snippets/auth-digest
- Access the server via a browser and check the response headers.
- Only one WWW-Authenticate header is present, like:
WWW-Authenticate: Digest realm="Access to the '/' path", nonce="742c60c1e89c86c32f14506ead7c4a7d", algorithm=SHA-512-256, charset=UTF-8, qop="auth", Digest realm="Access to the '/' path", nonce="bd4be7f3c89b5abd25d173d8fffb0a9a", algorithm=MD5, charset=UTF-8, qop="auth"
Expected Behavior
Should be something like:
WWW-Authenticate: Digest realm="Access to the '/' path", nonce="742c60c1e89c86c32f14506ead7c4a7d", algorithm=SHA-512-256, charset=UTF-8, qop="auth"
WWW-Authenticate: Digest realm="Access to the '/' path", nonce="bd4be7f3c89b5abd25d173d8fffb0a9a", algorithm=MD5, charset=UTF-8, qop="auth"
For the second issue, the server will respond "Unsupported charset in digest authentication header", just because the charset is not in the expected case.
For the third issue, the Ktor client may use the nonce "742c60c1e89c86c32f14506ead7c4a7d", along with the algorithm "MD5".
Structured concurrency is violated in WebRTC tests
jobs: MutableList<Job> are passed around the tests, which violates structured concurrency
Also, there are a couple of race conditions connected to data channels
3.5.0
released 18th May 2026
Client
CIO: Engine treats response header names case-sensitively and drops earlier repeated headers
Summary
CIO engine treats HTTP response header names case-sensitively, causing repeated headers with different casing to be ignored.
Affected Version
- Ktor client: latest (also reproducible in earlier versions)
- Engine: CIO
Steps to Reproduce
Server Response
HTTP/1.1 200 OK
Content-Length: 0
x-custom-header: Value2
x-custom-header: Value3
X-Custom-Header: Value1
Client Code
val client = HttpClient(CIO)
runBlocking {
val response = client.get("http://example.com/test")
println(response.headers.getAll("X-Custom-Header"))
}
Actual Behavior
Only the last header (X-Custom-Header: Value1) is retained. The previous two headers with lowercase x-custom-header are ignored.
// Output:
[Value1]
Expected Behavior
HTTP header names are case-insensitive per RFC 7230 §3.2. All values should be preserved regardless of case.
// Expected:
[Value2, Value3, Value1]
Or values should be accessible via case-insensitive lookups:
response.headers.getAll("X-Custom-Header") // [Value2, Value3, Value1]
response.headers.getAll("x-custom-header") // [Value2, Value3, Value1]
Additional Notes
- If the response includes only headers with the same case (e.g., all lowercase), values are preserved correctly:
x-custom-header: Value2
x-custom-header: Value3
→ Output:
[Value2, Value3]
- This issue does not occur with the OkHttp engine.
Suspected root cause
The issue may originate in:
ktor-client/ktor-client-cio/common/src/io/ktor/client/engine/cio/utils.kt
Specifically in the extension function:
fun HttpHeadersMap.toMap(): Map<String, List<String>>
While HttpHeadersMap internally treats all keys in lowercase (ensuring case-insensitivity), the toMap() function appears to expose them using original casing, which introduces inconsistency and case-sensitive behavior at the public API level.
This contradicts the internal logic and breaks the expected contract for header retrieval in HttpResponse.
Impact
This breaks expected behavior for headers like:
Set-CookieX-Forwarded-For- Custom headers sent with different casing from proxies or backends
It may cause integrations to behave incorrectly or lose data depending on the header casing used by the server.
Streaming call not cancelled when exception is thrown from HttpStatement.execute/body
Description
Consider this snippet, which requests a notification for when a container is "not-running":
val client = HttpClient(...) // same behaviour with CIO, OkHttp, Java, and I suspect other engines.
client
.preparePost("http://127.0.0.1:2375/containers/my_container_name/wait") {
parameter("condition", "not-running")
timeout {
// we could theoretically be waiting indefinitely for the container to be "not running"
// so set the timeouts accordingly
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
}
}
.execute { response ->
// the response Headers are received immediately
assert(response.status.value == 200)
// But the body of the response may stream very VERY slowly.
// Wouldn't we expect the below `throw` to cancel the call NOW?
// (without waiting for the next piece of data to arrive)
throw Throwable("does not close connection with streaming response, until another chunk/DATA frame arrives")
}
The initial response headers are received immediately (regardless of whether the container is running or not). But the actual notification of when the container is "not running" is transferred as a single streamed frame in the body (chunked-encoded in HTTP/1.1; DATA frame in HTTP/2). This means there can be an arbitrary delay between receiving the request headers and receiving some data. But if, meanwhile, an exception is thrown in the execute block, then the call to execute suspends until a frame arrives, which could (theoratically) take forever.
Actual behaviour
The call blocks until a frame arrives, which could (theoratically) take forever.
Expected behaviour
The call is automatically cancelled, promptly returning control to the caller of execute
Cases where an exception may be thrown in execute/body's block
-
using a
check.execute { response -> check(response.status.value == 200) { // can throw IllegalStateException "must be 200" } ... } -
using a flow operator that uses
collectWhile(firstOrNull, transformWhile, etc.val aFlow = flow { client.prepareGet("...").body { channel: ByteReadChannel -> while (true) { // imagine a line is sent by the server only every minute emit(channel.readLine()) // can throw `AbortFlowException` from `collectWhile` } } } aFlow.takeWhile { ... } // or any other operator calling `collectWhile`
Fix suggestion
In HttpResponse.cleanup, calling complete on the "call job" does indeed cancel the underlying request channels, but does not throw CancellationException in coroutines that are already suspended and awaiting content, thus leading to this behaviour.
While perhaps it would be possible to always call cancel, the snippet below would fix this bug for the cases given above, while keeping the current behaviour if nothing is thrown from block.
Note that the cancellation is the same as the one triggered in attachToUserJob .
public suspend fun <T> execute(block: suspend (response: HttpResponse) -> T): T = unwrapRequestTimeoutException {
val response = fetchStreamingResponse()
var cause: Throwable? = null
try {
...
} catch (t: Throwable) {
cause = t
throw t
} finally {
response.cleanup(cause)
}
}
internal suspend fun HttpResponse.cleanup(cause: Throwable?) {
val job = coroutineContext.job as CompletableJob
job.apply {
if (cause != null) {
// same cancellation as in `attachToUserJob`
cancel(kotlinx.coroutines.CancellationException(cause.message))
} else {
complete()
}
...
join()
}
}
In a way, the fix is very similar to how emitAllImpl is implemented.
Comment on an existing issue
To avoid being flagged as duplicate to KTOR-2510, which has a similar title to this issue, let me make a couple of remarks about it:
- it is wildly outdated;
- it does not specify what should happen when an exception is thrown; it simply states that something used to hang.
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.
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.
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.
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
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
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)
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
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.
Compiler Plugin
OpenAPI code inference misses property delegation
For path parameters and query parameters, we can also use property delegation operators for reading, like val limit by call.queryParameters. This should be handled the same way as the getter methods. Property delegation also handles basic formatting, which can be used in the schema.
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 Jetty Jakarta: Provide an easy way to disable SNI hostname validation
We've added a new option in the Jetty engine that allows for easier override of the default hostname verification. Using this option, you can remap hostnames without hitting errors on secure endpoints, which is useful when testing in local environments.
The engine configuration now has a secureRequestCustomizer lambda field that provides access to the server's org.eclipse.jetty.server.SecureRequestCustomizer instance. This is where you can override the SNI policy.
You can disable host checking and SNI with the new option like so:
embeddedServer(
Jetty,
configure = {
secureRequestCustomizer = {
isSniHostCheck = false
isSniRequired = false
}
}
)
Documentation for Sessions: Add a way to create a user session shared for all user devices or look up sessions of the same user
Description
Adds two new capabilities to the Sessions plugin:
1. Session ID generation based on ApplicationCall: The identity() builder function now accepts (ApplicationCall) -> String, allowing session IDs to be derived from request context (e.g., authenticated user, IP address, custom headers).
2. Clear session by ID: A new clear(sessionId) API on CurrentSession (and SessionTrackerById.clearById()) allows invalidating a session by its storage ID without needing the active call. This is useful for scenarios like logging out all devices for a user or expiring sessions from background jobs.
The previous identity(() -> String) and sessionIdProvider properties are deprecated in favour of the new call-aware overloads.
Code example
// Generate session ID from the authenticated user
install(Sessions) {
cookie<UserSession>("user_session", storage = RedisSessionStorage()) {
identity { call ->
call.principal<UserIdPrincipal>()?.name ?: generateSessionId()
}
}
}
// Clear a specific session by ID (e.g., from a "logout all devices" endpoint)
post("/logout/{sessionId}") {
val sessionId = call.parameters["sessionId"]!!
call.sessions.clear<UserSession>(sessionId)
call.respond(HttpStatusCode.OK)
}
Migration guide
Does this change require a migration guide for existing users?
- [ ] Yes
- [x] No
Related Links (Optional)
KTOR-8300 Sessions: Add a way to create a user session shared for all user devices, or look up sessions of the same user
Documentation for Custom SSE heartbeat function
This was a feature requested in https://github.com/ktorio/ktor/issues/5518 and fixed in https://github.com/ktorio/ktor/pull/5572
It provides a new option for Ktor's SSE support on the server to fully customise the heartbeat event by using a custom provider function:
heartbeat {
period = 30.seconds
eventProvider = { ServerSentEvent(data = "ts=${Clock.System.now()}") }
}
This allows for sending more useful information from the server at regular intervals like status updates and timestamps.
Add a note for java default version in "Heroku"
They say about Heroku (version 2.3.6):
I think it should be noted that Heroku uses java 8 by default so if you compile the app with java 11 or higher then the app may fail to run in Heroku. I had to specify the version of java required in a systems.properties file, as stated in their docs: https://devcenter.heroku.com/articles/java-support#specifying-a-java-version
Add code sample projects for "I/O Interoperability"
They say about I/O Interoperability (version 3.4.1):
Add ktor-client and tor-server samples/integration
Beginner tutorials are out-of-date with the project generator
The entire tutorial is confusing for beginners. From "Create, open, and run a new Ktor project" to "Integrate a database with Kotlin, Ktor, and Exposed," everything seems outdated compared to the newest Ktor project generator. The issues include:
-
The code structure doesn't match. The documentation says there are "Application.kt" and "Routing.kt" under src/main/kotlin, but the actual files are "main.kt" and "Routing.kt".
-
For the testing part, the document shows:
application {
module()
}
but it seems it should simply be configure().
-
Starting from "How to create RESTful APIs in Kotlin with Ktor", the document mentions adding the Routing plugin, but it's already part of the Ktor starter kit — there is no Routing plugin in the Ktor Project Generator.
-
If the above issues can be resolved, then for "Integrate a database with Kotlin, Ktor, and Exposed", it's impossible to proceed from the beginning. The document says to delete the file "UsersSchema.kt" and remove content from the function "configureDatabases()" in "Databases.kt". However, neither "UsersSchema.kt" nor "Databases.kt" exist, and the function "configureDatabases()" cannot be found anywhere in the project.
Please understand that for a Kotlin and Ktor beginner like me, it's very frustrating to learn from tutorials with so many inconsistencies. Are there up-to-date documents I can check?
Update the Integrate a database tutorial
- update generated file names and code blocks
- update the snippets project
- update screenshots
Update the Create a website tutorial
- Update the steps in the tutorial to match the new file structure in generated Ktor projects
- Update the snippets project
Update the Create a WebSocket application tutorial
- update generated file names and code blocks
- update the snippets project
- update screenshots
Clarify that default url path must contain a trailing slash
axu says about Ktor documentation
Api symbol: io.ktor.http.path:
defaultRequest {
host = "www.abc.com"
url {
protocol = URLProtocol.HTTPS
path("path1", "path2", "path3")
}
}my code shown above, then when i run my app and send a request, the last path is dropped, both android(okhttp) and iOS(darwin) platform. for example: what i expected is: https://www.abc.com/path1/path2/path3, but it's actually:
https://www.abc.com/path1/path2, the path3 is missing.then i added a path4 like this:
path("path1", "path2", "path3", "path4"),
my url is correct like this: https://www.abc.com/path1/path2/path3,the path4 ismissing。
Update the Handle requests and generate responses tutorial
Update the tutorial steps and sample project to match the new structure of generated Ktor projects.
Update generated project in the RESTful API tutorial
- update screenshots
- test and application file names
- snippets project
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
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
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.
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.
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
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?
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")
}
}
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>()
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)
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()
}
}
}
}
}
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"
}
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.
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?
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.
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.
Shared
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)
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.
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.)
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
HTTP/2: SSE connections block response flushing for other requests on same connection
When using HTTP/2 (h2 or h2c), Server-Sent Events (SSE) connections prevent response flushing for other concurrent requests multiplexed on the same connection. This causes those requests to hang indefinitely in "pending" state.
Environment
- Ktor version: 3.4.0
- Server: Netty
- Protocol: HTTP/2 (h2c through reverse proxy like HAProxy, or direct h2)
Root Cause
In NettyHttpResponsePipeline.flushIfNeeded(), the flush condition checks activeRequests.value == 0L. However, SSE connections never complete (they stream indefinitely), so activeRequests never reaches zero. This blocks flushing for all other requests on the same HTTP/2 connection.
internal fun flushIfNeeded() {
if (
isDataNotFlushed.value &&
httpHandlerState.isChannelReadCompleted.value &&
httpHandlerState.activeRequests.value == 0L // Never true when SSE is active
// with at least one open connection
) {
context.flush()
// ...
}
}
Proposed fix
Track streaming responses separately and flush when only streaming requests remain active: maintain an internal val streamingResponses count in NettyHttpHandlerState, then flush if those are the only one actives.
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.
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.
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