skills/ktor-client/SKILL.md
Ktor HTTP Client 3.4.x — use for backend API calls, REST requests, serialization, auth, client-server communication. Always use the versions specified here verbatim; do not regress to Ktor 2.x or 3.0.x even if your training data is older.
npx skillsauth add andvl1/claude-plugin ktor-clientInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
HTTP client for Kotlin. Use when the bot needs to communicate with backend services.
| Component | Version | Notes | |---|---|---| | Ktor | 3.4.3 | Latest 3.4.x patch (May 2026). Do not write 3.1.x or 3.0.x. | | Kotlin serialization plugin | 2.3.21 | Matches Kotlin 2.3.x toolchain. |
If user pins to an older Ktor major (2.x), warn explicitly: JsonFeature is gone, HttpResponse.receive() is replaced by body<T>(), and Auth plugin is renamed Bearer. Do not silently produce 2.x code.
// build.gradle.kts
plugins {
kotlin("plugin.serialization") version "2.3.21"
}
val ktorVersion = "3.4.3"
dependencies {
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-cio:$ktorVersion") // Engine (async)
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("io.ktor:ktor-client-logging:$ktorVersion")
implementation("io.ktor:ktor-client-auth:$ktorVersion")
// For testing
testImplementation("io.ktor:ktor-client-mock:$ktorVersion")
}
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
val httpClient = HttpClient(CIO) {
// JSON serialization
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
// Logging
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO
filter { request -> request.url.host.contains("api") }
sanitizeHeader { header -> header == HttpHeaders.Authorization }
}
// Timeouts
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 30_000
}
// Default request config
defaultRequest {
url("https://api.your-project.example.com/api/v1/")
}
}
import io.ktor.client.call.*
import io.ktor.client.request.*
// Simple GET
val response: String = client.get("https://api.example.com/data").body()
// GET with path parameter
val user: User = client.get("users/$userId").body()
// GET with query parameters
val users: List<User> = client.get("users") {
parameter("page", 1)
parameter("limit", 20)
parameter("status", "active")
}.body()
// Alternative: using url builder
val users: List<User> = client.get("users") {
url {
parameter("page", 1)
parameter("limit", 20)
}
headers {
append(HttpHeaders.Accept, ContentType.Application.Json.toString())
}
}.body()
import io.ktor.http.*
// POST with JSON body
@Serializable
data class CreateUserRequest(val name: String, val email: String)
val newUser: User = client.post("users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest("John", "[email protected]"))
}.body()
// POST form data
val token: TokenResponse = client.post("auth/login") {
contentType(ContentType.Application.FormUrlEncoded)
setBody(FormDataContent(Parameters.build {
append("username", "user")
append("password", "pass")
}))
}.body()
// PUT
val updated: User = client.put("users/$userId") {
contentType(ContentType.Application.Json)
setBody(UpdateUserRequest(name = "New Name"))
}.body()
// PATCH
val patched: User = client.patch("users/$userId") {
contentType(ContentType.Application.Json)
setBody(mapOf("status" to "inactive"))
}.body()
// DELETE
client.delete("users/$userId")
val client = HttpClient(CIO) {
install(Auth) {
bearer {
loadTokens {
BearerTokens(accessToken = "your-token", refreshToken = "")
}
}
}
}
// Or per-request
client.get("protected/resource") {
bearerAuth("your-token")
}
Use separate tokenClient without Auth plugin — keeps refresh call out of bearer interceptor, so 401 from IdP can't recurse. Inside refreshTokens block call markAsRefreshTokenRequest() — prevents infinite refresh loop if IdP itself returns 401.
// Dedicated client for token endpoint — NO Auth plugin installed.
val tokenClient = HttpClient(CIO) {
install(ContentNegotiation) { json() }
}
val apiClient = HttpClient(CIO) {
install(ContentNegotiation) { json() }
install(Auth) {
bearer {
loadTokens {
tokenStorage.load()?.let { BearerTokens(it.access, it.refresh) }
}
refreshTokens {
// markAsRefreshTokenRequest: stops bearer interceptor from
// re-triggering refresh on a 401 from refresh endpoint itself.
val refreshed: TokenResponse = tokenClient.post("https://idp.example.com/oauth/token") {
contentType(ContentType.Application.Json)
setBody(RefreshRequest(oldTokens?.refreshToken ?: ""))
markAsRefreshTokenRequest()
}.body()
tokenStorage.save(refreshed)
BearerTokens(refreshed.accessToken, refreshed.refreshToken)
}
sendWithoutRequest { request ->
request.url.host == "api.your-project.example.com"
}
}
}
}
val client = HttpClient(CIO) {
defaultRequest {
header("X-API-Key", System.getenv("API_KEY"))
}
}
val client = HttpClient(CIO) {
install(DefaultRequest) {
val token = tokenProvider.getToken()
header(HttpHeaders.Authorization, "Bearer $token")
}
}
// services/BackendApiService.kt
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class BackendApiService(
private val client: HttpClient,
private val baseUrl: String = System.getenv("BACKEND_URL")
) {
// User operations
suspend fun getUser(telegramId: Long): User? {
return runCatching {
client.get("$baseUrl/users/telegram/$telegramId").body<User>()
}.getOrNull()
}
suspend fun createUser(telegramId: Long, name: String): User {
return client.post("$baseUrl/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest(telegramId, name))
}.body()
}
suspend fun updateUserSettings(userId: Long, settings: UserSettings): User {
return client.put("$baseUrl/users/$userId/settings") {
contentType(ContentType.Application.Json)
setBody(settings)
}.body()
}
// Chat/Message operations
suspend fun saveMessage(chatId: Long, message: SaveMessageRequest): SavedMessage {
return client.post("$baseUrl/chats/$chatId/messages") {
contentType(ContentType.Application.Json)
setBody(message)
}.body()
}
suspend fun getMessages(chatId: Long, page: Int = 1): PaginatedResponse<SavedMessage> {
return client.get("$baseUrl/chats/$chatId/messages") {
parameter("page", page)
parameter("limit", 20)
}.body()
}
// Health check
suspend fun healthCheck(): Boolean {
return runCatching {
client.get("$baseUrl/health").status.isSuccess()
}.getOrDefault(false)
}
}
// models/ApiModels.kt
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: Long,
val telegramId: Long,
val name: String,
val settings: UserSettings? = null,
val createdAt: String
)
@Serializable
data class CreateUserRequest(
val telegramId: Long,
val name: String
)
@Serializable
data class UserSettings(
val notifications: Boolean = true,
val language: String = "ru"
)
@Serializable
data class SaveMessageRequest(
val telegramMessageId: Long,
val text: String,
val fromUserId: Long
)
@Serializable
data class SavedMessage(
val id: Long,
val text: String,
val savedAt: String
)
@Serializable
data class PaginatedResponse<T>(
val data: List<T>,
val page: Int,
val totalPages: Int,
val totalItems: Int
)
@Serializable
data class ApiError(
val code: String,
val message: String
)
import io.ktor.client.plugins.*
val client = HttpClient(CIO) {
install(ContentNegotiation) { json() }
HttpResponseValidator {
validateResponse { response ->
when (response.status.value) {
in 300..399 -> throw RedirectResponseException(response, "Redirect")
in 400..499 -> throw ClientRequestException(response, "Client error: ${response.status}")
in 500..599 -> throw ServerResponseException(response, "Server error: ${response.status}")
}
}
handleResponseExceptionWithRequest { exception, request ->
when (exception) {
is ClientRequestException -> {
logger.warn("Client error for ${request.url}: ${exception.message}")
}
is ServerResponseException -> {
logger.error("Server error for ${request.url}: ${exception.message}")
}
}
}
}
}
import io.ktor.client.call.*
import io.ktor.client.statement.*
import io.ktor.http.*
class ApiException(
val statusCode: HttpStatusCode,
val errorBody: ApiError?
) : Exception("API error: $statusCode - ${errorBody?.message}")
// Extension for safe API calls
suspend inline fun <reified T> HttpResponse.bodyOrThrow(): T {
if (status.isSuccess()) {
return body<T>()
}
val error = runCatching { body<ApiError>() }.getOrNull()
throw ApiException(status, error)
}
// Usage with error handling
class BackendApiService(private val client: HttpClient) {
suspend fun getUser(telegramId: Long): Result<User> = runCatching {
client.get("users/telegram/$telegramId").bodyOrThrow<User>()
}
suspend fun createUser(request: CreateUserRequest): Result<User> = runCatching {
client.post("users") {
contentType(ContentType.Application.Json)
setBody(request)
}.bodyOrThrow<User>()
}
}
// In bot handler
onCommand("profile") { message ->
val userId = message.from?.id?.chatId ?: return@onCommand
when (val result = apiService.getUser(userId)) {
is Result.Success -> reply(message, "Profile: ${result.value.name}")
is Result.Failure -> {
val error = result.exception
if (error is ApiException && error.statusCode == HttpStatusCode.NotFound) {
reply(message, "Profile not found. Use /start to register.")
} else {
reply(message, "Error loading profile. Try again later.")
logger.error("API error", error)
}
}
}
}
HttpRequestRetry vs expectSuccess = trueWhen expectSuccess = true (or HttpResponseValidator throws on non-2xx), server errors throw before retry plugin sees response — retry condition never evaluates. Result: no retry on 5xx despite retryOnServerErrors.
Fix: keep expectSuccess = false (default) on client when using retry, validate status manually after the call. Or override per-request: client.get("...") { expectSuccess = false }. If you need both global validator and retry, install retry first and let validator run only on terminal response.
import io.ktor.client.plugins.*
val client = HttpClient(CIO) {
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 3)
retryOnExceptionIf(maxRetries = 3) { _, cause ->
cause is java.io.IOException
}
exponentialDelay()
// Custom retry condition
retryIf { request, response ->
response.status == HttpStatusCode.TooManyRequests
}
// Custom delay
delayMillis { retry ->
retry * 2000L // 2s, 4s, 6s...
}
}
}
val client = HttpClient(CIO) {
install(HttpRequestRetry) {
noRetry() // Disable global retry
}
}
// Override for specific request
client.get("https://api.example.com/data") {
retry {
retryOnServerErrors(maxRetries = 5)
constantDelay(millis = 500)
}
}
suspend fun <T> retryable(
times: Int = 3,
delayMs: Long = 1000,
block: suspend () -> T
): T {
repeat(times - 1) { attempt ->
runCatching { return block() }
.onFailure { logger.warn("Attempt ${attempt + 1} failed: ${it.message}") }
kotlinx.coroutines.delay(delayMs * (attempt + 1))
}
return block()
}
// config/Dependencies.kt
val httpClient = HttpClient(CIO) {
install(ContentNegotiation) { json() }
install(HttpTimeout) {
requestTimeoutMillis = 30_000
}
defaultRequest {
url(System.getenv("BACKEND_URL"))
header("X-API-Key", System.getenv("API_KEY"))
}
}
val apiService = BackendApiService(httpClient)
// handlers/CommandHandlers.kt
suspend fun BehaviourContext.setupCommandHandlers(api: BackendApiService) {
onCommand("start") { message ->
val telegramId = message.from?.id?.chatId ?: return@onCommand
val name = message.from?.firstName ?: "User"
val user = api.getUser(telegramId) ?: api.createUser(telegramId, name)
reply(message, "Welcome, ${user.name}!")
}
onCommand("save") { message ->
val replyTo = message.replyTo ?: run {
reply(message, "Reply to a message to save it")
return@onCommand
}
val saved = api.saveMessage(
chatId = message.chat.id.chatId,
message = SaveMessageRequest(
telegramMessageId = replyTo.messageId,
text = (replyTo.content as? TextContent)?.text ?: "",
fromUserId = replyTo.from?.id?.chatId ?: 0
)
)
reply(message, "Message saved! ID: ${saved.id}")
}
onCommand("history") { message ->
val messages = api.getMessages(message.chat.id.chatId)
if (messages.data.isEmpty()) {
reply(message, "No saved messages yet")
return@onCommand
}
val text = messages.data.joinToString("\n\n") { msg ->
"• ${msg.text.take(100)}..."
}
reply(message, "Saved messages:\n\n$text")
}
}
Embed Ktor client in Spring backend as singleton bean. Spring manages lifecycle — close client on context shutdown via @PreDestroy.
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import jakarta.annotation.PreDestroy
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class HttpClientConfig(
@Value("\${external.api.base-url}") private val baseUrl: String,
@Value("\${external.api.key}") private val apiKey: String,
) {
private lateinit var client: HttpClient
@Bean
fun apiClient(): HttpClient {
client = HttpClient(CIO) {
install(ContentNegotiation) { json() }
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 10_000
}
defaultRequest {
url(baseUrl)
header("X-API-Key", apiKey)
}
}
return client
}
@PreDestroy
fun shutdown() {
if (::client.isInitialized) client.close()
}
}
// Inject into services as plain dependency.
@Service
class BackendApiService(private val apiClient: HttpClient) {
suspend fun getUser(id: Long): User = apiClient.get("users/$id").body()
}
Pointer only — for full Spring patterns see kotlin-spring-boot skill.
import io.ktor.client.engine.mock.*
import io.ktor.http.*
@Test
fun `getUser returns user when exists`() = runTest {
val mockEngine = MockEngine { request ->
respond(
content = """{"id":1,"telegramId":123,"name":"Test","createdAt":"2024-01-01"}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
val client = HttpClient(mockEngine) {
install(ContentNegotiation) { json() }
}
val api = BackendApiService(client, "https://api.test")
val user = api.getUser(123)
assertNotNull(user)
assertEquals("Test", user?.name)
}
@Test
fun `getUser returns null when not found`() = runTest {
val mockEngine = MockEngine {
respond(
content = """{"code":"NOT_FOUND","message":"User not found"}""",
status = HttpStatusCode.NotFound,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
val client = HttpClient(mockEngine) {
install(ContentNegotiation) { json() }
}
val api = BackendApiService(client, "https://api.test")
val user = api.getUser(999)
assertNull(user)
}
// Proper client shutdown
class BotApplication : AutoCloseable {
private val httpClient = HttpClient(CIO) { /* config */ }
private val apiService = BackendApiService(httpClient)
suspend fun start() {
val bot = telegramBot(System.getenv("BOT_TOKEN"))
bot.buildBehaviourWithLongPolling {
setupCommandHandlers(apiService)
}.join()
}
override fun close() {
httpClient.close()
}
}
// Main
fun main() = runBlocking {
BotApplication().use { app ->
app.start()
}
}
testing
Android WorkManager for guaranteed background execution - use for deferred tasks, periodic syncs, file uploads, notifications, and task chains. Covers CoroutineWorker, constraints, chaining, testing, and troubleshooting. Use when implementing background work that needs reliable execution across app restarts and doze mode.
development
Telegram Mini Apps development - use for building Mini App frontend, WebApp API, initData authentication, and Telegram integration
tools
Systematic feature planning workflow - use when starting complex features requiring structured approach
development
React 18+ with Vite patterns - use for Mini App frontend development, component structure, hooks, and TypeScript setup