CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/maven-io-ktor--ktor-client-core-tvosarm64

Ktor HTTP Client Core for tvOS ARM64 - multiplatform asynchronous HTTP client library with coroutines support

Pending
Overview
Eval results
Files

caching.mddocs/

HTTP Caching

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.

Core Cache API

HttpCache Plugin

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)
    }
}

HttpCacheStorage Interface

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)
}

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
}

Built-in Storage Implementations

UnlimitedCacheStorage

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)
}

DisabledCacheStorage

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
}

Basic Usage

Simple HTTP Caching

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()

Custom Storage Configuration

val client = HttpClient {
    install(HttpCache) {
        publicStorage = UnlimitedCacheStorage()
        privateStorage = UnlimitedCacheStorage()
    }
}

Disabling Cache

val client = HttpClient {
    install(HttpCache) {
        publicStorage = DisabledCacheStorage
        privateStorage = DisabledCacheStorage
    }
}

Cache Control Headers

Server-Side Cache Control

The cache respects standard HTTP cache control headers sent by servers:

  • Cache-Control: max-age=3600 - Cache for 1 hour
  • Cache-Control: no-cache - Revalidate on each request
  • Cache-Control: no-store - Don't cache at all
  • Cache-Control: private - Cache only in private storage
  • Cache-Control: public - Cache in public storage
  • Expires - Absolute expiration date
  • ETag - Entity tag for conditional requests
  • Last-Modified - Last modification date for conditional requests
val 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")

Client-Side Cache Control

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
}

Conditional Requests

ETag Validation

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 plugin

Last-Modified Validation

val 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 modified

Advanced Caching Features

Vary Header Handling

The 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 languages

Cache Invalidation

class 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)
    }
}

Custom Storage Implementations

Size-Limited Cache Storage

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
        }
    }
}

Persistent Cache Storage

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")
    }
}

Redis Cache Storage

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")
    }
}

Cache Debugging and Monitoring

Cache Hit/Miss Logging

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)
    }
}

Cache Statistics

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
)

Cache Configuration Best Practices

Production Cache Setup

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"))
    }
}

Development Cache Setup

val client = HttpClient {
    install(HttpCache) {
        if (developmentMode) {
            // Disable caching in development
            publicStorage = DisabledCacheStorage
            privateStorage = DisabledCacheStorage
        } else {
            publicStorage = UnlimitedCacheStorage()
            privateStorage = UnlimitedCacheStorage()
        }
    }
}

Best Practices

  1. Choose appropriate storage: Use memory storage for short-lived applications, persistent storage for long-running ones
  2. Set size limits: Implement size limits to prevent memory issues
  3. Respect cache headers: Always honor server-sent cache control headers
  4. Handle stale data: Implement proper handling of stale cache entries
  5. Monitor cache performance: Track hit/miss ratios to optimize cache effectiveness
  6. Secure cached data: Be careful with sensitive data in shared cache storage
  7. Clean up expired entries: Implement cleanup mechanisms for persistent storage
  8. Handle cache invalidation: Provide mechanisms to invalidate specific cache entries when needed
  9. Test cache behavior: Test your application with and without caching enabled
  10. Consider network conditions: Adjust cache strategies based on network reliability

Install with Tessl CLI

npx tessl i tessl/maven-io-ktor--ktor-client-core-tvosarm64

docs

builtin-plugins.md

caching.md

cookies.md

engine-configuration.md

forms.md

http-client.md

index.md

plugin-system.md

request-building.md

response-handling.md

response-observation.md

utilities.md

websockets.md

tile.json