Ktor HTTP Client Core for tvOS ARM64 - multiplatform asynchronous HTTP client library with coroutines support
—
The Ktor HTTP Client Core provides comprehensive HTTP response caching functionality through the HttpCache plugin. This enables automatic caching of HTTP responses with configurable storage backends, cache control header support, and custom cache validation logic to improve performance and reduce network requests.
The main plugin for HTTP response caching that automatically handles cache storage and retrieval based on HTTP semantics.
object HttpCache : HttpClientPlugin<HttpCache.Config, HttpCache> {
class Config {
var publicStorage: HttpCacheStorage = UnlimitedCacheStorage()
var privateStorage: HttpCacheStorage = UnlimitedCacheStorage()
var useOldConnection: Boolean = true
fun publicStorage(storage: HttpCacheStorage)
fun privateStorage(storage: HttpCacheStorage)
}
}Base interface for implementing custom cache storage backends.
interface HttpCacheStorage {
suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry?
suspend fun findAll(url: Url): Set<HttpCacheEntry>
suspend fun store(url: Url, data: HttpCacheEntry)
}Represents a cached HTTP response with metadata and content.
data class HttpCacheEntry(
val url: Url,
val statusCode: HttpStatusCode,
val requestTime: GMTDate,
val responseTime: GMTDate,
val version: HttpProtocolVersion,
val expires: GMTDate,
val vary: Map<String, String>,
val varyKeys: Set<String>,
val body: ByteArray,
val headers: Headers
) {
fun isStale(): Boolean
fun age(): Long
}Default storage implementation that caches all responses in memory without size limits.
class UnlimitedCacheStorage : HttpCacheStorage {
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry?
override suspend fun findAll(url: Url): Set<HttpCacheEntry>
override suspend fun store(url: Url, data: HttpCacheEntry)
}No-op storage implementation that disables caching completely.
object DisabledCacheStorage : HttpCacheStorage {
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry? = null
override suspend fun findAll(url: Url): Set<HttpCacheEntry> = emptySet()
override suspend fun store(url: Url, data: HttpCacheEntry) = Unit
}val client = HttpClient {
install(HttpCache)
}
// First request - response is cached
val response1 = client.get("https://api.example.com/data")
val data1 = response1.bodyAsText()
// Second request - served from cache if still valid
val response2 = client.get("https://api.example.com/data")
val data2 = response2.bodyAsText() // Same as data1, served from cache
client.close()val client = HttpClient {
install(HttpCache) {
publicStorage = UnlimitedCacheStorage()
privateStorage = UnlimitedCacheStorage()
}
}val client = HttpClient {
install(HttpCache) {
publicStorage = DisabledCacheStorage
privateStorage = DisabledCacheStorage
}
}The cache respects standard HTTP cache control headers sent by servers:
Cache-Control: max-age=3600 - Cache for 1 hourCache-Control: no-cache - Revalidate on each requestCache-Control: no-store - Don't cache at allCache-Control: private - Cache only in private storageCache-Control: public - Cache in public storageExpires - Absolute expiration dateETag - Entity tag for conditional requestsLast-Modified - Last modification date for conditional requestsval client = HttpClient {
install(HttpCache)
}
// Server response with cache headers:
// Cache-Control: max-age=300, public
// ETag: "abc123"
val response = client.get("https://api.example.com/data")
// Subsequent request within 5 minutes will be served from cache
val cachedResponse = client.get("https://api.example.com/data")Control caching behavior on individual requests:
val client = HttpClient {
install(HttpCache)
}
// Force fresh request (bypass cache)
val freshResponse = client.get("https://api.example.com/data") {
header("Cache-Control", "no-cache")
}
// Only use cache if available
val cachedOnlyResponse = client.get("https://api.example.com/data") {
header("Cache-Control", "only-if-cached")
}
// Set maximum acceptable age
val maxStaleResponse = client.get("https://api.example.com/data") {
header("Cache-Control", "max-stale=60") // Accept cache up to 1 minute stale
}val client = HttpClient {
install(HttpCache)
}
// First request stores ETag
val response1 = client.get("https://api.example.com/resource")
// Subsequent request includes If-None-Match header
// Server returns 304 Not Modified if unchanged
val response2 = client.get("https://api.example.com/resource")
// Automatically handled by cache pluginval client = HttpClient {
install(HttpCache)
}
// First request stores Last-Modified date
val response1 = client.get("https://api.example.com/document")
// Subsequent request includes If-Modified-Since header
val response2 = client.get("https://api.example.com/document")
// Returns cached version if not modifiedThe cache properly handles Vary headers to store different versions of responses based on request headers:
val client = HttpClient {
install(HttpCache)
}
// Server responds with: Vary: Accept-Language, Accept-Encoding
val englishResponse = client.get("https://api.example.com/content") {
header("Accept-Language", "en-US")
}
val frenchResponse = client.get("https://api.example.com/content") {
header("Accept-Language", "fr-FR")
}
// Different cached versions for different languagesclass InvalidatingCacheStorage : HttpCacheStorage {
private val storage = UnlimitedCacheStorage()
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry? {
val entry = storage.find(url, vary)
return if (entry?.isStale() == true) {
null // Treat stale entries as cache miss
} else {
entry
}
}
override suspend fun findAll(url: Url): Set<HttpCacheEntry> {
return storage.findAll(url).filterNot { it.isStale() }.toSet()
}
override suspend fun store(url: Url, data: HttpCacheEntry) {
storage.store(url, data)
}
}class LimitedSizeCacheStorage(
private val maxSizeBytes: Long
) : HttpCacheStorage {
private val cache = mutableMapOf<String, HttpCacheEntry>()
private var currentSize = 0L
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry? {
val key = generateKey(url, vary)
return cache[key]?.takeUnless { it.isStale() }
}
override suspend fun findAll(url: Url): Set<HttpCacheEntry> {
return cache.values.filter {
it.url.toString().startsWith(url.toString()) && !it.isStale()
}.toSet()
}
override suspend fun store(url: Url, data: HttpCacheEntry) {
val key = generateKey(url, data.vary)
val entrySize = data.body.size.toLong()
// Evict entries if necessary
while (currentSize + entrySize > maxSizeBytes && cache.isNotEmpty()) {
evictLeastRecentlyUsed()
}
if (entrySize <= maxSizeBytes) {
cache[key] = data
currentSize += entrySize
}
}
private fun generateKey(url: Url, vary: Map<String, String>): String {
return "${url}:${vary.entries.sortedBy { it.key }.joinToString(",") { "${it.key}=${it.value}" }}"
}
private fun evictLeastRecentlyUsed() {
// Implementation for LRU eviction
val oldestEntry = cache.values.minByOrNull { it.responseTime }
if (oldestEntry != null) {
cache.entries.removeAll { it.value == oldestEntry }
currentSize -= oldestEntry.body.size
}
}
}class FileCacheStorage(
private val cacheDirectory: File
) : HttpCacheStorage {
init {
cacheDirectory.mkdirs()
}
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry? {
val cacheFile = getCacheFile(url, vary)
return if (cacheFile.exists()) {
try {
deserializeCacheEntry(cacheFile.readBytes())
} catch (e: Exception) {
null // Corrupted cache entry
}
} else {
null
}
}
override suspend fun findAll(url: Url): Set<HttpCacheEntry> {
return cacheDirectory.listFiles()
?.mapNotNull { file ->
try {
val entry = deserializeCacheEntry(file.readBytes())
if (entry.url.toString().startsWith(url.toString())) entry else null
} catch (e: Exception) {
null
}
}?.toSet() ?: emptySet()
}
override suspend fun store(url: Url, data: HttpCacheEntry) {
val cacheFile = getCacheFile(url, data.vary)
val serializedData = serializeCacheEntry(data)
cacheFile.writeBytes(serializedData)
}
private fun getCacheFile(url: Url, vary: Map<String, String>): File {
val filename = generateCacheFileName(url, vary)
return File(cacheDirectory, filename)
}
private fun generateCacheFileName(url: Url, vary: Map<String, String>): String {
// Generate unique filename based on URL and vary parameters
val urlHash = url.toString().hashCode()
val varyHash = vary.hashCode()
return "${urlHash}_${varyHash}.cache"
}
private fun serializeCacheEntry(entry: HttpCacheEntry): ByteArray {
// Implement serialization (JSON, protobuf, etc.)
TODO("Implement serialization")
}
private fun deserializeCacheEntry(data: ByteArray): HttpCacheEntry {
// Implement deserialization
TODO("Implement deserialization")
}
}class RedisCacheStorage(
private val redisClient: RedisClient,
private val keyPrefix: String = "ktor-cache:"
) : HttpCacheStorage {
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry? {
val key = generateRedisKey(url, vary)
val serializedEntry = redisClient.get(key) ?: return null
return try {
deserializeCacheEntry(serializedEntry)
} catch (e: Exception) {
null
}
}
override suspend fun findAll(url: Url): Set<HttpCacheEntry> {
val pattern = "$keyPrefix${url.encodedPath}*"
val keys = redisClient.keys(pattern)
return keys.mapNotNull { key ->
try {
redisClient.get(key)?.let { deserializeCacheEntry(it) }
} catch (e: Exception) {
null
}
}.toSet()
}
override suspend fun store(url: Url, data: HttpCacheEntry) {
val key = generateRedisKey(url, data.vary)
val serializedEntry = serializeCacheEntry(data)
// Set with TTL based on cache entry expiration
val ttlSeconds = (data.expires.timestamp - Clock.System.now().epochSeconds).coerceAtLeast(0)
redisClient.setex(key, ttlSeconds.toInt(), serializedEntry)
}
private fun generateRedisKey(url: Url, vary: Map<String, String>): String {
val varyString = vary.entries.sortedBy { it.key }.joinToString(",") { "${it.key}=${it.value}" }
return "$keyPrefix${url}:$varyString"
}
private fun serializeCacheEntry(entry: HttpCacheEntry): String {
// Implement JSON or other serialization
TODO("Implement serialization")
}
private fun deserializeCacheEntry(data: String): HttpCacheEntry {
// Implement deserialization
TODO("Implement deserialization")
}
}class LoggingCacheStorage(
private val delegate: HttpCacheStorage,
private val logger: Logger
) : HttpCacheStorage by delegate {
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry? {
val entry = delegate.find(url, vary)
if (entry != null) {
logger.info("Cache HIT for $url")
} else {
logger.info("Cache MISS for $url")
}
return entry
}
override suspend fun store(url: Url, data: HttpCacheEntry) {
logger.info("Caching response for $url (size: ${data.body.size} bytes)")
delegate.store(url, data)
}
}class StatisticsCacheStorage(
private val delegate: HttpCacheStorage
) : HttpCacheStorage by delegate {
private var hitCount = AtomicLong(0)
private var missCount = AtomicLong(0)
private var storeCount = AtomicLong(0)
val hitRatio: Double get() = hitCount.get().toDouble() / (hitCount.get() + missCount.get())
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry? {
val entry = delegate.find(url, vary)
if (entry != null) {
hitCount.incrementAndGet()
} else {
missCount.incrementAndGet()
}
return entry
}
override suspend fun store(url: Url, data: HttpCacheEntry) {
storeCount.incrementAndGet()
delegate.store(url, data)
}
fun getStatistics(): CacheStatistics {
return CacheStatistics(
hits = hitCount.get(),
misses = missCount.get(),
stores = storeCount.get(),
hitRatio = hitRatio
)
}
}
data class CacheStatistics(
val hits: Long,
val misses: Long,
val stores: Long,
val hitRatio: Double
)val client = HttpClient {
install(HttpCache) {
// Use separate storages for public and private content
publicStorage = LimitedSizeCacheStorage(maxSizeBytes = 50 * 1024 * 1024) // 50MB
privateStorage = FileCacheStorage(File("cache/private"))
}
}val client = HttpClient {
install(HttpCache) {
if (developmentMode) {
// Disable caching in development
publicStorage = DisabledCacheStorage
privateStorage = DisabledCacheStorage
} else {
publicStorage = UnlimitedCacheStorage()
privateStorage = UnlimitedCacheStorage()
}
}
}Install with Tessl CLI
npx tessl i tessl/maven-io-ktor--ktor-client-core-tvosarm64