From d86a330926592b714152764d0a7696826fee8586 Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal Date: Mon, 30 Mar 2026 08:58:15 +0530 Subject: [PATCH 1/6] Added Health Check Module for Ktor --- .../ktor-server-health-check/build.gradle.kts | 9 + .../server/plugins/healthcheck/HealthCheck.kt | 112 ++++++++++ .../plugins/healthcheck/HealthCheckConfig.kt | 83 +++++++ .../plugins/healthcheck/HealthCheckTest.kt | 207 ++++++++++++++++++ settings.gradle.kts | 1 + 5 files changed, 412 insertions(+) create mode 100644 ktor-server/ktor-server-plugins/ktor-server-health-check/build.gradle.kts create mode 100644 ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt create mode 100644 ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt create mode 100644 ktor-server/ktor-server-plugins/ktor-server-health-check/common/test/io/ktor/server/plugins/healthcheck/HealthCheckTest.kt diff --git a/ktor-server/ktor-server-plugins/ktor-server-health-check/build.gradle.kts b/ktor-server/ktor-server-plugins/ktor-server-health-check/build.gradle.kts new file mode 100644 index 00000000000..41dac368fd1 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-health-check/build.gradle.kts @@ -0,0 +1,9 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +description = "Ktor server health check and readiness/liveness endpoints plugin" + +plugins { + id("ktorbuild.project.server-plugin") +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt new file mode 100644 index 00000000000..f02914a70ea --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.plugins.healthcheck + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import kotlinx.coroutines.* + +/** + * A plugin that provides health check endpoints for Kubernetes readiness and liveness probes. + * + * Intercepts GET requests matching configured paths and responds with a JSON health status. + * All checks within an endpoint run concurrently. The overall status is UP only when every + * individual check reports healthy. + * + * Response format: + * ```json + * { + * "status": "UP", + * "checks": [ + * { "name": "database", "status": "UP" }, + * { "name": "redis", "status": "DOWN", "error": "Connection refused" } + * ] + * } + * ``` + * + * - HTTP 200 when all checks pass (status: UP) + * - HTTP 503 when any check fails (status: DOWN) + * + * Example: + * ```kotlin + * install(HealthCheck) { + * readiness("/ready") { + * check("database") { dataSource.connection.isValid(1) } + * check("redis") { redisClient.ping(); true } + * } + * liveness("/health") { + * check("memory") { Runtime.getRuntime().freeMemory() > threshold } + * } + * } + * ``` + */ +public val HealthCheck: ApplicationPlugin = + createApplicationPlugin("HealthCheck", ::HealthCheckConfig) { + val endpoints = pluginConfig.endpoints.toList() + + onCall { call -> + if (call.response.isCommitted) return@onCall + if (call.request.httpMethod != HttpMethod.Get) return@onCall + + val path = call.request.path() + val endpoint = endpoints.find { it.path == path } ?: return@onCall + + val results = coroutineScope { + endpoint.checks.map { namedCheck -> + async { evaluateCheck(namedCheck) } + }.awaitAll() + } + + val overallUp = results.all { it.status == CheckStatus.UP } + val httpStatus = if (overallUp) HttpStatusCode.OK else HttpStatusCode.ServiceUnavailable + + call.respondText( + buildHealthResponse(overallUp, results), + ContentType.Application.Json, + httpStatus + ) + } + } + +private suspend fun evaluateCheck(namedCheck: NamedCheck): CheckResult = + try { + val healthy = namedCheck.check() + CheckResult(namedCheck.name, if (healthy) CheckStatus.UP else CheckStatus.DOWN) + } catch (cause: Exception) { + CheckResult(namedCheck.name, CheckStatus.DOWN, cause.message) + } + +internal enum class CheckStatus { UP, DOWN } + +internal class CheckResult(val name: String, val status: CheckStatus, val error: String? = null) + +private fun buildHealthResponse(overallUp: Boolean, results: List): String = buildString { + append("{\"status\":\"") + append(if (overallUp) "UP" else "DOWN") + append("\",\"checks\":[") + results.forEachIndexed { index, result -> + if (index > 0) append(',') + append("{\"name\":\"") + append(result.name.escapeJson()) + append("\",\"status\":\"") + append(result.status.name) + append('"') + if (result.error != null) { + append(",\"error\":\"") + append(result.error.escapeJson()) + append('"') + } + append('}') + } + append("]}") +} + +private fun String.escapeJson(): String = replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") diff --git a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt new file mode 100644 index 00000000000..7cb055c07c9 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.plugins.healthcheck + +import io.ktor.server.application.* + +/** + * Configuration for the [HealthCheck] plugin. + * + * Use [readiness] and [liveness] to define health check endpoints with named checks. + * + * Example: + * ```kotlin + * install(HealthCheck) { + * readiness("/ready") { + * check("database") { dataSource.connection.isValid(1) } + * } + * liveness("/health") { + * check("heartbeat") { true } + * } + * } + * ``` + */ +@KtorDsl +public class HealthCheckConfig { + internal val endpoints = mutableListOf() + + /** + * Configures a readiness endpoint at the given [path]. + * + * Readiness checks determine whether the application is ready to serve traffic. + * Failed readiness checks cause Kubernetes to stop routing requests to the pod. + * + * @param path the URL path for the readiness endpoint (e.g., "/ready") + * @param block builder for adding individual health checks + */ + public fun readiness(path: String, block: HealthCheckBuilder.() -> Unit) { + endpoints += HealthCheckEndpoint(path.ensureLeadingSlash(), HealthCheckBuilder().apply(block).checks.toList()) + } + + /** + * Configures a liveness endpoint at the given [path]. + * + * Liveness checks determine whether the application is still running. + * Failed liveness checks cause Kubernetes to restart the container. + * + * @param path the URL path for the liveness endpoint (e.g., "/health") + * @param block builder for adding individual health checks + */ + public fun liveness(path: String, block: HealthCheckBuilder.() -> Unit) { + endpoints += HealthCheckEndpoint(path.ensureLeadingSlash(), HealthCheckBuilder().apply(block).checks.toList()) + } +} + +/** + * Builder for configuring individual health checks within a readiness or liveness endpoint. + */ +@KtorDsl +public class HealthCheckBuilder { + internal val checks = mutableListOf() + + /** + * Adds a named health check. + * + * The [block] should return `true` if the component is healthy, `false` if unhealthy. + * If [block] throws an exception, the check is treated as unhealthy and the exception + * message is included in the JSON response. + * + * @param name a human-readable identifier for this check (e.g., "database", "redis") + * @param block a suspend function returning `true` for healthy, `false` for unhealthy + */ + public fun check(name: String, block: suspend () -> Boolean) { + checks += NamedCheck(name, block) + } +} + +internal class NamedCheck(val name: String, val check: suspend () -> Boolean) + +internal class HealthCheckEndpoint(val path: String, val checks: List) + +private fun String.ensureLeadingSlash(): String = if (startsWith("/")) this else "/$this" diff --git a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/test/io/ktor/server/plugins/healthcheck/HealthCheckTest.kt b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/test/io/ktor/server/plugins/healthcheck/HealthCheckTest.kt new file mode 100644 index 00000000000..e915db75524 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/test/io/ktor/server/plugins/healthcheck/HealthCheckTest.kt @@ -0,0 +1,207 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.plugins.healthcheck + +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import kotlin.test.* + +class HealthCheckTest { + + @Test + fun `liveness endpoint returns UP when all checks pass`() = testApplication { + install(HealthCheck) { + liveness("/health") { + check("alive") { true } + } + } + + client.get("/health").let { response -> + assertEquals(HttpStatusCode.OK, response.status) + val body = response.bodyAsText() + assertContains(body, "\"status\":\"UP\"") + assertContains(body, "\"name\":\"alive\"") + assertContains(body, "\"status\":\"UP\"") + } + } + + @Test + fun `readiness endpoint returns DOWN when a check fails`() = testApplication { + install(HealthCheck) { + readiness("/ready") { + check("ok") { true } + check("failing") { false } + } + } + + client.get("/ready").let { response -> + assertEquals(HttpStatusCode.ServiceUnavailable, response.status) + val body = response.bodyAsText() + assertContains(body, "\"status\":\"DOWN\"") + } + } + + @Test + fun `check that throws exception reports DOWN with error message`() = testApplication { + install(HealthCheck) { + liveness("/health") { + check("broken") { error("connection refused") } + } + } + + client.get("/health").let { response -> + assertEquals(HttpStatusCode.ServiceUnavailable, response.status) + val body = response.bodyAsText() + assertContains(body, "\"status\":\"DOWN\"") + assertContains(body, "\"error\":\"connection refused\"") + } + } + + @Test + fun `endpoint with no checks returns UP`() = testApplication { + install(HealthCheck) { + liveness("/health") {} + } + + client.get("/health").let { response -> + assertEquals(HttpStatusCode.OK, response.status) + val body = response.bodyAsText() + assertContains(body, "\"status\":\"UP\"") + assertContains(body, "\"checks\":[]") + } + } + + @Test + fun `multiple endpoints work independently`() = testApplication { + install(HealthCheck) { + liveness("/health") { + check("alive") { true } + } + readiness("/ready") { + check("database") { false } + } + } + + assertEquals(HttpStatusCode.OK, client.get("/health").status) + assertEquals(HttpStatusCode.ServiceUnavailable, client.get("/ready").status) + } + + @Test + fun `non-health-check paths are not intercepted`() = testApplication { + install(HealthCheck) { + liveness("/health") { + check("ok") { true } + } + } + + routing { + get("/other") { call.respondText("hello") } + } + + val response = client.get("/other") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("hello", response.bodyAsText()) + } + + @Test + fun `only GET requests are handled`() = testApplication { + install(HealthCheck) { + liveness("/health") { + check("ok") { true } + } + } + + val getResponse = client.get("/health") + assertEquals(HttpStatusCode.OK, getResponse.status) + assertContains(getResponse.bodyAsText(), "\"status\":\"UP\"") + + val postResponse = client.post("/health") + assertFalse(postResponse.bodyAsText().contains("\"status\":\"UP\"")) + } + + @Test + fun `response content type is application json`() = testApplication { + install(HealthCheck) { + liveness("/health") { + check("ok") { true } + } + } + + val response = client.get("/health") + assertEquals(ContentType.Application.Json, response.contentType()?.withoutParameters()) + } + + @Test + fun `all checks reported individually in response`() = testApplication { + install(HealthCheck) { + readiness("/ready") { + check("db") { true } + check("cache") { true } + check("queue") { true } + } + } + + client.get("/ready").let { response -> + assertEquals(HttpStatusCode.OK, response.status) + val body = response.bodyAsText() + assertContains(body, "\"name\":\"db\"") + assertContains(body, "\"name\":\"cache\"") + assertContains(body, "\"name\":\"queue\"") + } + } + + @Test + fun `error message with special characters is escaped in JSON`() = testApplication { + install(HealthCheck) { + liveness("/health") { + check("broken") { error("value with \"quotes\" and\nnewline") } + } + } + + client.get("/health").let { response -> + assertEquals(HttpStatusCode.ServiceUnavailable, response.status) + val body = response.bodyAsText() + assertContains(body, "\\\"quotes\\\"") + assertContains(body, "\\n") + } + } + + @Test + fun `path without leading slash is normalized`() = testApplication { + install(HealthCheck) { + liveness("health") { + check("ok") { true } + } + } + + val response = client.get("/health") + assertEquals(HttpStatusCode.OK, response.status) + assertContains(response.bodyAsText(), "\"status\":\"UP\"") + } + + @Test + fun `mixed healthy and unhealthy checks result in DOWN`() = testApplication { + install(HealthCheck) { + readiness("/ready") { + check("healthy") { true } + check("unhealthy") { false } + check("error") { throw RuntimeException("timeout") } + } + } + + client.get("/ready").let { response -> + assertEquals(HttpStatusCode.ServiceUnavailable, response.status) + val body = response.bodyAsText() + assertContains(body, "\"status\":\"DOWN\"") + assertContains(body, "\"name\":\"healthy\"") + assertContains(body, "\"name\":\"unhealthy\"") + assertContains(body, "\"error\":\"timeout\"") + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index fe016796b01..63eafb8f31d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -81,6 +81,7 @@ projects { +"ktor-server-double-receive" +"ktor-server-forwarded-header" +"ktor-server-freemarker" + +"ktor-server-health-check" +"ktor-server-hsts" +"ktor-server-html-builder" +"ktor-server-htmx" From adc233a9b885e59ee6c74fdb281336cec1a0655e Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal Date: Mon, 30 Mar 2026 14:19:04 +0530 Subject: [PATCH 2/6] Added README for Health Check module --- .../ktor-server-health-check/README.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 ktor-server/ktor-server-plugins/ktor-server-health-check/README.md diff --git a/ktor-server/ktor-server-plugins/ktor-server-health-check/README.md b/ktor-server/ktor-server-plugins/ktor-server-health-check/README.md new file mode 100644 index 00000000000..3a1416fffa9 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-health-check/README.md @@ -0,0 +1,115 @@ +# ktor-server-health-check + +A Ktor server plugin that provides health check endpoints for Kubernetes readiness and liveness probes (and any other orchestration system that polls HTTP health endpoints). + +## Motivation + +Every production Ktor application deployed to Kubernetes needs health and readiness endpoints. Currently, developers write these manually every time. This plugin provides a declarative DSL - similar to Spring Actuator, Micronaut Health, or Quarkus SmallRye Health - so that health endpoints are consistent, correct, and require minimal boilerplate. + +## Installation + +Add the dependency to your `build.gradle.kts`: + +```kotlin +dependencies { + implementation("io.ktor:ktor-server-health-check:$ktor_version") +} +``` + +## Quick Start + +```kotlin +import io.ktor.server.application.* +import io.ktor.server.plugins.healthcheck.* + +fun Application.module() { + install(HealthCheck) { + readiness("/ready") { + check("database") { dataSource.connection.isValid(1) } + check("redis") { redisClient.ping(); true } + } + liveness("/health") { + check("memory") { Runtime.getRuntime().freeMemory() > 10_000_000 } + } + } +} +``` + +## DSL Reference + +### `readiness(path) { ... }` + +Configures a readiness endpoint. Readiness checks determine whether the application is ready to serve traffic. Failed readiness checks cause Kubernetes to stop routing requests to the pod. + +### `liveness(path) { ... }` + +Configures a liveness endpoint. Liveness checks determine whether the application is still running. Failed liveness checks cause Kubernetes to restart the container. + +### `check(name) { ... }` + +Adds a named health check within a readiness or liveness block. The lambda should return `true` for healthy, `false` for unhealthy. If the lambda throws an exception, the check is treated as unhealthy and the exception message is included in the response. + +## Response Format + +The plugin responds with `application/json` and uses standard HTTP status codes: + +| Condition | HTTP Status | `status` field | +|---|---|---| +| All checks pass | 200 OK | `UP` | +| Any check fails | 503 Service Unavailable | `DOWN` | + +### All healthy + +```json +{ + "status": "UP", + "checks": [ + { "name": "database", "status": "UP" }, + { "name": "redis", "status": "UP" } + ] +} +``` + +### One or more unhealthy + +```json +{ + "status": "DOWN", + "checks": [ + { "name": "database", "status": "UP" }, + { "name": "redis", "status": "DOWN", "error": "Connection refused" } + ] +} +``` + +## Kubernetes Configuration + +```yaml +apiVersion: apps/v1 +kind: Deployment +spec: + template: + spec: + containers: + - name: app + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 +``` + +## Design Decisions + +- **Concurrent checks**: All checks within an endpoint run concurrently via `coroutineScope` + `async`, minimizing probe latency when multiple I/O-bound checks are configured. +- **No external dependencies**: JSON responses are built with `StringBuilder` - no serialization library required. The plugin does not require `ContentNegotiation`. +- **`onCall` interception**: The plugin intercepts at the call level (before routing), matching configured paths directly. This means health endpoints work even without installing `Routing`. +- **GET-only**: Only `GET` requests are handled, matching the HTTP method used by Kubernetes probes and load balancers. +- **Multiplatform**: The plugin is implemented in `commonMain` and works on all Ktor server targets (JVM, Native, JS). From 34b12a3db30850addf81f4a529bfd7e330effdc1 Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal <77500658+Subhanshu20135@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:02:52 +0530 Subject: [PATCH 3/6] Update ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../plugins/healthcheck/HealthCheckConfig.kt | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt index 7cb055c07c9..56d1a6df86a 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt @@ -36,8 +36,28 @@ public class HealthCheckConfig { * @param path the URL path for the readiness endpoint (e.g., "/ready") * @param block builder for adding individual health checks */ +public class HealthCheckConfig { + internal val endpoints = mutableListOf() + + private fun addEndpoint(path: String, block: HealthCheckBuilder.() -> Unit) { + val normalizedPath = path.ensureLeadingSlash() + require(endpoints.none { it.path == normalizedPath }) { + "Health check endpoint path '$normalizedPath' is already configured" + } + endpoints += HealthCheckEndpoint(normalizedPath, HealthCheckBuilder().apply(block).checks.toList()) + } + + /** + * Configures a readiness endpoint at the given [path]. + * + * Readiness checks determine whether the application is ready to serve requests. + * Failed readiness checks prevent Kubernetes from routing traffic to the pod. + * + * `@param` path the URL path for the readiness endpoint (e.g., "/ready") + * `@param` block builder for adding individual health checks + */ public fun readiness(path: String, block: HealthCheckBuilder.() -> Unit) { - endpoints += HealthCheckEndpoint(path.ensureLeadingSlash(), HealthCheckBuilder().apply(block).checks.toList()) + addEndpoint(path, block) } /** @@ -46,11 +66,13 @@ public class HealthCheckConfig { * Liveness checks determine whether the application is still running. * Failed liveness checks cause Kubernetes to restart the container. * - * @param path the URL path for the liveness endpoint (e.g., "/health") - * @param block builder for adding individual health checks + * `@param` path the URL path for the liveness endpoint (e.g., "/health") + * `@param` block builder for adding individual health checks */ public fun liveness(path: String, block: HealthCheckBuilder.() -> Unit) { - endpoints += HealthCheckEndpoint(path.ensureLeadingSlash(), HealthCheckBuilder().apply(block).checks.toList()) + addEndpoint(path, block) + } +} } } From 742f75c1312b1d17b188cc29146a21294b7f18cc Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal <77500658+Subhanshu20135@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:03:09 +0530 Subject: [PATCH 4/6] Update ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../server/plugins/healthcheck/HealthCheck.kt | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt index f02914a70ea..193d96ea5c9 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt @@ -105,8 +105,22 @@ private fun buildHealthResponse(overallUp: Boolean, results: List): append("]}") } -private fun String.escapeJson(): String = replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") +private fun String.escapeJson(): String = buildString(length) { + for (ch in this@escapeJson) { + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> if (ch.code < 0x20) { + append("\\u") + append(ch.code.toString(16).padStart(4, '0')) + } else { + append(ch) + } + } + } +} From ef5ec22bfa075bee907a9e850fcf5e5c83e0527d Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal Date: Tue, 31 Mar 2026 00:04:28 +0530 Subject: [PATCH 5/6] Improve exception handling in HealthCheck and add POST route test --- .../src/io/ktor/server/plugins/healthcheck/HealthCheck.kt | 4 +++- .../io/ktor/server/plugins/healthcheck/HealthCheckTest.kt | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt index 193d96ea5c9..6dffde28127 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt @@ -76,8 +76,10 @@ private suspend fun evaluateCheck(namedCheck: NamedCheck): CheckResult = try { val healthy = namedCheck.check() CheckResult(namedCheck.name, if (healthy) CheckStatus.UP else CheckStatus.DOWN) + } catch (cause: CancellationException) { + throw cause } catch (cause: Exception) { - CheckResult(namedCheck.name, CheckStatus.DOWN, cause.message) + CheckResult(namedCheck.name, CheckStatus.DOWN, "Health Check Failed") } internal enum class CheckStatus { UP, DOWN } diff --git a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/test/io/ktor/server/plugins/healthcheck/HealthCheckTest.kt b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/test/io/ktor/server/plugins/healthcheck/HealthCheckTest.kt index e915db75524..e653a3e51d0 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/test/io/ktor/server/plugins/healthcheck/HealthCheckTest.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/test/io/ktor/server/plugins/healthcheck/HealthCheckTest.kt @@ -117,12 +117,17 @@ class HealthCheckTest { } } + routing { + post("/health") { call.respondText("posted") } + } + val getResponse = client.get("/health") assertEquals(HttpStatusCode.OK, getResponse.status) assertContains(getResponse.bodyAsText(), "\"status\":\"UP\"") val postResponse = client.post("/health") - assertFalse(postResponse.bodyAsText().contains("\"status\":\"UP\"")) + assertEquals(HttpStatusCode.OK, postResponse.status) + assertEquals("posted", postResponse.bodyAsText()) } @Test From fe974f97ca222d6e4db2e90d1e0c56603e9e5e94 Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal Date: Tue, 31 Mar 2026 00:25:24 +0530 Subject: [PATCH 6/6] Improve error messages for failed health checks and fix documentation formatting --- .../server/plugins/healthcheck/HealthCheck.kt | 2 +- .../plugins/healthcheck/HealthCheckConfig.kt | 23 ++++--------------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt index 6dffde28127..2c884e69130 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt @@ -79,7 +79,7 @@ private suspend fun evaluateCheck(namedCheck: NamedCheck): CheckResult = } catch (cause: CancellationException) { throw cause } catch (cause: Exception) { - CheckResult(namedCheck.name, CheckStatus.DOWN, "Health Check Failed") + CheckResult(namedCheck.name, CheckStatus.DOWN, cause.message ?: "Health Check Failed") } internal enum class CheckStatus { UP, DOWN } diff --git a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt index 56d1a6df86a..1713fd83250 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt @@ -5,6 +5,7 @@ package io.ktor.server.plugins.healthcheck import io.ktor.server.application.* +import io.ktor.utils.io.* /** * Configuration for the [HealthCheck] plugin. @@ -27,18 +28,6 @@ import io.ktor.server.application.* public class HealthCheckConfig { internal val endpoints = mutableListOf() - /** - * Configures a readiness endpoint at the given [path]. - * - * Readiness checks determine whether the application is ready to serve traffic. - * Failed readiness checks cause Kubernetes to stop routing requests to the pod. - * - * @param path the URL path for the readiness endpoint (e.g., "/ready") - * @param block builder for adding individual health checks - */ -public class HealthCheckConfig { - internal val endpoints = mutableListOf() - private fun addEndpoint(path: String, block: HealthCheckBuilder.() -> Unit) { val normalizedPath = path.ensureLeadingSlash() require(endpoints.none { it.path == normalizedPath }) { @@ -53,8 +42,8 @@ public class HealthCheckConfig { * Readiness checks determine whether the application is ready to serve requests. * Failed readiness checks prevent Kubernetes from routing traffic to the pod. * - * `@param` path the URL path for the readiness endpoint (e.g., "/ready") - * `@param` block builder for adding individual health checks + * @param path the URL path for the readiness endpoint (e.g., "/ready") + * @param block builder for adding individual health checks */ public fun readiness(path: String, block: HealthCheckBuilder.() -> Unit) { addEndpoint(path, block) @@ -66,15 +55,13 @@ public class HealthCheckConfig { * Liveness checks determine whether the application is still running. * Failed liveness checks cause Kubernetes to restart the container. * - * `@param` path the URL path for the liveness endpoint (e.g., "/health") - * `@param` block builder for adding individual health checks + * @param path the URL path for the liveness endpoint (e.g., "/health") + * @param block builder for adding individual health checks */ public fun liveness(path: String, block: HealthCheckBuilder.() -> Unit) { addEndpoint(path, block) } } - } -} /** * Builder for configuring individual health checks within a readiness or liveness endpoint.