Ktor HTTP Client Core for tvOS ARM64 - multiplatform asynchronous HTTP client library with coroutines support
—
The Ktor HTTP Client Core provides response observation functionality through the ResponseObserver plugin for monitoring and intercepting HTTP responses. This enables logging, metrics collection, custom processing, and debugging of HTTP client interactions with full access to response data and metadata.
The main plugin for observing HTTP responses that allows registration of multiple observers for different purposes.
object ResponseObserver : HttpClientPlugin<ResponseObserver.Config, ResponseObserver> {
class Config {
internal val responseHandlers = mutableListOf<suspend (HttpResponse) -> Unit>()
fun onResponse(block: suspend (response: HttpResponse) -> Unit)
fun onResponse(handler: ResponseHandler)
}
interface ResponseHandler {
suspend fun handle(response: HttpResponse)
}
}val client = HttpClient {
install(ResponseObserver) {
onResponse { response ->
println("Response received: ${response.status} from ${response.call.request.url}")
println("Content-Type: ${response.contentType()}")
println("Content-Length: ${response.contentLength()}")
}
}
}
// All responses will be logged
val response1 = client.get("https://httpbin.org/json")
val response2 = client.post("https://httpbin.org/post") {
setBody("test data")
}
client.close()val client = HttpClient {
install(ResponseObserver) {
// Log response status
onResponse { response ->
println("[${System.currentTimeMillis()}] ${response.status}")
}
// Track response times
onResponse { response ->
val requestTime = response.requestTime
val responseTime = response.responseTime
val duration = responseTime.timestamp - requestTime.timestamp
println("Request took ${duration}ms")
}
// Monitor error responses
onResponse { response ->
if (response.status.value >= 400) {
println("ERROR: ${response.status} - ${response.bodyAsText()}")
}
}
}
}class MetricsResponseHandler : ResponseObserver.ResponseHandler {
private val responseTimes = mutableListOf<Long>()
private val statusCounts = mutableMapOf<Int, Int>()
private val errorResponses = mutableListOf<ErrorResponse>()
override suspend fun handle(response: HttpResponse) {
// Track response time
val duration = response.responseTime.timestamp - response.requestTime.timestamp
responseTimes.add(duration)
// Count status codes
val statusCode = response.status.value
statusCounts[statusCode] = statusCounts.getOrDefault(statusCode, 0) + 1
// Collect error details
if (statusCode >= 400) {
errorResponses.add(
ErrorResponse(
url = response.call.request.url.toString(),
status = response.status,
timestamp = System.currentTimeMillis(),
body = response.bodyAsText()
)
)
}
}
fun getAverageResponseTime(): Double {
return if (responseTimes.isNotEmpty()) {
responseTimes.average()
} else 0.0
}
fun getStatusDistribution(): Map<Int, Int> = statusCounts.toMap()
fun getErrorResponses(): List<ErrorResponse> = errorResponses.toList()
}
data class ErrorResponse(
val url: String,
val status: HttpStatusCode,
val timestamp: Long,
val body: String
)
// Usage
val metricsHandler = MetricsResponseHandler()
val client = HttpClient {
install(ResponseObserver) {
onResponse(metricsHandler)
}
}
// After making requests, analyze metrics
println("Average response time: ${metricsHandler.getAverageResponseTime()}ms")
println("Status distribution: ${metricsHandler.getStatusDistribution()}")val client = HttpClient {
install(ResponseObserver) {
onResponse { response ->
val url = response.call.request.url
// Only observe specific endpoints
if (url.encodedPath.startsWith("/api/")) {
when {
response.status.value >= 500 -> {
logServerError(response)
}
response.status.value >= 400 -> {
logClientError(response)
}
response.status == HttpStatusCode.OK -> {
logSuccessfulRequest(response)
}
}
}
}
}
}
suspend fun logServerError(response: HttpResponse) {
println("SERVER ERROR: ${response.status} at ${response.call.request.url}")
println("Response body: ${response.bodyAsText()}")
// Send to error monitoring service
}
suspend fun logClientError(response: HttpResponse) {
println("CLIENT ERROR: ${response.status} at ${response.call.request.url}")
// Log for debugging
}
suspend fun logSuccessfulRequest(response: HttpResponse) {
val size = response.contentLength() ?: -1
println("SUCCESS: ${response.call.request.url} (${size} bytes)")
}class ResponseSizeMonitor : ResponseObserver.ResponseHandler {
private val sizeBuckets = mutableMapOf<String, MutableList<Long>>()
override suspend fun handle(response: HttpResponse) {
val endpoint = extractEndpoint(response.call.request.url)
val size = response.contentLength() ?: estimateBodySize(response)
sizeBuckets.getOrPut(endpoint) { mutableListOf() }.add(size)
}
private fun extractEndpoint(url: Url): String {
return "${url.host}${url.encodedPath}"
}
private suspend fun estimateBodySize(response: HttpResponse): Long {
// For responses without content-length, estimate from body
val body = response.bodyAsText()
return body.toByteArray(Charsets.UTF_8).size.toLong()
}
fun getAverageSize(endpoint: String): Double? {
return sizeBuckets[endpoint]?.average()
}
fun getLargestResponse(): Pair<String, Long>? {
return sizeBuckets.entries
.mapNotNull { (endpoint, sizes) ->
sizes.maxOrNull()?.let { endpoint to it }
}
.maxByOrNull { it.second }
}
}val client = HttpClient {
install(ResponseObserver) {
onResponse { response ->
val contentType = response.contentType()
val url = response.call.request.url
when {
contentType?.match(ContentType.Application.Json) == true -> {
analyzeJsonResponse(response)
}
contentType?.match(ContentType.Text.Html) == true -> {
analyzeHtmlResponse(response)
}
contentType?.match(ContentType.Image.Any) == true -> {
analyzeImageResponse(response)
}
else -> {
println("Unknown content type: $contentType from $url")
}
}
}
}
}
suspend fun analyzeJsonResponse(response: HttpResponse) {
try {
val jsonText = response.bodyAsText()
// Parse and validate JSON
val isValidJson = try {
Json.parseToJsonElement(jsonText)
true
} catch (e: Exception) {
false
}
println("JSON response from ${response.call.request.url}: valid=$isValidJson, size=${jsonText.length}")
} catch (e: Exception) {
println("Failed to analyze JSON response: ${e.message}")
}
}
suspend fun analyzeHtmlResponse(response: HttpResponse) {
val html = response.bodyAsText()
val titlePattern = Regex("<title>(.*?)</title>", RegexOption.IGNORE_CASE)
val title = titlePattern.find(html)?.groupValues?.get(1) ?: "No title"
println("HTML response from ${response.call.request.url}: title='$title', size=${html.length}")
}
suspend fun analyzeImageResponse(response: HttpResponse) {
val size = response.contentLength() ?: -1
val contentType = response.contentType()
println("Image response from ${response.call.request.url}: type=$contentType, size=$size bytes")
}class PerformanceMonitor : ResponseObserver.ResponseHandler {
private val requestMetrics = mutableMapOf<String, RequestMetrics>()
override suspend fun handle(response: HttpResponse) {
val endpoint = "${response.call.request.method.value} ${response.call.request.url.encodedPath}"
val duration = response.responseTime.timestamp - response.requestTime.timestamp
val metrics = requestMetrics.getOrPut(endpoint) { RequestMetrics() }
metrics.addMeasurement(duration, response.status.value)
}
fun getSlowEndpoints(thresholdMs: Long): List<String> {
return requestMetrics.entries
.filter { it.value.averageDuration > thresholdMs }
.map { it.key }
}
fun getErrorProneEndpoints(errorRateThreshold: Double): List<String> {
return requestMetrics.entries
.filter { it.value.errorRate > errorRateThreshold }
.map { it.key }
}
}
class RequestMetrics {
private val durations = mutableListOf<Long>()
private val statusCodes = mutableListOf<Int>()
fun addMeasurement(duration: Long, statusCode: Int) {
durations.add(duration)
statusCodes.add(statusCode)
}
val averageDuration: Double get() = durations.average()
val maxDuration: Long get() = durations.maxOrNull() ?: 0L
val minDuration: Long get() = durations.minOrNull() ?: 0L
val errorRate: Double get() {
val errorCount = statusCodes.count { it >= 400 }
return if (statusCodes.isNotEmpty()) {
errorCount.toDouble() / statusCodes.size
} else 0.0
}
val totalRequests: Int get() = statusCodes.size
}class ResponseArchiver(private val archiveDirectory: File) : ResponseObserver.ResponseHandler {
init {
archiveDirectory.mkdirs()
}
override suspend fun handle(response: HttpResponse) {
if (shouldArchive(response)) {
archiveResponse(response)
}
}
private fun shouldArchive(response: HttpResponse): Boolean {
// Archive successful responses from specific endpoints
return response.status == HttpStatusCode.OK &&
response.call.request.url.encodedPath.startsWith("/api/data/")
}
private suspend fun archiveResponse(response: HttpResponse) {
val timestamp = System.currentTimeMillis()
val url = response.call.request.url
val filename = "${url.host}_${url.encodedPath.replace("/", "_")}_$timestamp.json"
val archiveFile = File(archiveDirectory, filename)
val responseData = ResponseArchive(
url = url.toString(),
status = response.status.value,
headers = response.headers.toMap(),
body = response.bodyAsText(),
timestamp = timestamp,
duration = response.responseTime.timestamp - response.requestTime.timestamp
)
val json = Json.encodeToString(responseData)
archiveFile.writeText(json)
}
}
@Serializable
data class ResponseArchive(
val url: String,
val status: Int,
val headers: Map<String, List<String>>,
val body: String,
val timestamp: Long,
val duration: Long
)val client = HttpClient {
install(ResponseObserver) {
onResponse { response ->
validateResponse(response)
}
}
}
suspend fun validateResponse(response: HttpResponse) {
val url = response.call.request.url
val contentType = response.contentType()
// Validate content type matches expectations
when {
url.encodedPath.startsWith("/api/") -> {
if (contentType?.match(ContentType.Application.Json) != true) {
println("WARNING: API endpoint returned non-JSON: $contentType")
}
}
url.encodedPath.endsWith(".json") -> {
if (contentType?.match(ContentType.Application.Json) != true) {
println("WARNING: JSON file has incorrect content type: $contentType")
}
}
}
// Validate response integrity
if (response.status == HttpStatusCode.OK) {
val contentLength = response.contentLength()
if (contentLength == 0L) {
println("WARNING: Empty successful response from $url")
}
}
// Validate required headers
validateRequiredHeaders(response)
}
fun validateRequiredHeaders(response: HttpResponse) {
val requiredHeaders = listOf("content-type", "date")
val missingHeaders = requiredHeaders.filter { header ->
response.headers[header] == null
}
if (missingHeaders.isNotEmpty()) {
println("WARNING: Missing headers: $missingHeaders from ${response.call.request.url}")
}
}class ResponseDebugger : ResponseObserver.ResponseHandler {
override suspend fun handle(response: HttpResponse) {
if (isDebugEnabled()) {
printDetailedResponse(response)
}
}
private fun isDebugEnabled(): Boolean {
return System.getProperty("http.debug") == "true"
}
private suspend fun printDetailedResponse(response: HttpResponse) {
val request = response.call.request
println("=== HTTP Response Debug ===")
println("Request: ${request.method.value} ${request.url}")
println("Status: ${response.status}")
println("Response Time: ${response.responseTime}")
println("Duration: ${response.responseTime.timestamp - response.requestTime.timestamp}ms")
println("\nRequest Headers:")
request.headers.forEach { name, values ->
values.forEach { value ->
println(" $name: $value")
}
}
println("\nResponse Headers:")
response.headers.forEach { name, values ->
values.forEach { value ->
println(" $name: $value")
}
}
println("\nResponse Body:")
val body = response.bodyAsText()
if (body.length > 1000) {
println("${body.take(1000)}... (truncated, total length: ${body.length})")
} else {
println(body)
}
println("=========================")
}
}class ErrorCollector : ResponseObserver.ResponseHandler {
private val errors = Collections.synchronizedList(mutableListOf<ErrorInfo>())
override suspend fun handle(response: HttpResponse) {
if (response.status.value >= 400) {
val errorInfo = ErrorInfo(
timestamp = System.currentTimeMillis(),
url = response.call.request.url.toString(),
method = response.call.request.method.value,
status = response.status.value,
statusDescription = response.status.description,
responseBody = response.bodyAsText(),
requestHeaders = response.call.request.headers.toMap(),
responseHeaders = response.headers.toMap()
)
errors.add(errorInfo)
}
}
fun getErrors(): List<ErrorInfo> = errors.toList()
fun getErrorsByStatus(statusCode: Int): List<ErrorInfo> {
return errors.filter { it.status == statusCode }
}
fun getRecentErrors(sinceMs: Long): List<ErrorInfo> {
val cutoff = System.currentTimeMillis() - sinceMs
return errors.filter { it.timestamp >= cutoff }
}
fun clear() = errors.clear()
}
data class ErrorInfo(
val timestamp: Long,
val url: String,
val method: String,
val status: Int,
val statusDescription: String,
val responseBody: String,
val requestHeaders: Map<String, List<String>>,
val responseHeaders: Map<String, List<String>>
)Install with Tessl CLI
npx tessl i tessl/maven-io-ktor--ktor-client-core-tvosarm64