0
# HTTP Caching
1
2
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.
3
4
## Core Cache API
5
6
### HttpCache Plugin
7
8
The main plugin for HTTP response caching that automatically handles cache storage and retrieval based on HTTP semantics.
9
10
```kotlin { .api }
11
object HttpCache : HttpClientPlugin<HttpCache.Config, HttpCache> {
12
class Config {
13
var publicStorage: HttpCacheStorage = UnlimitedCacheStorage()
14
var privateStorage: HttpCacheStorage = UnlimitedCacheStorage()
15
var useOldConnection: Boolean = true
16
17
fun publicStorage(storage: HttpCacheStorage)
18
fun privateStorage(storage: HttpCacheStorage)
19
}
20
}
21
```
22
23
### HttpCacheStorage Interface
24
25
Base interface for implementing custom cache storage backends.
26
27
```kotlin { .api }
28
interface HttpCacheStorage {
29
suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry?
30
suspend fun findAll(url: Url): Set<HttpCacheEntry>
31
suspend fun store(url: Url, data: HttpCacheEntry)
32
}
33
```
34
35
### HttpCacheEntry
36
37
Represents a cached HTTP response with metadata and content.
38
39
```kotlin { .api }
40
data class HttpCacheEntry(
41
val url: Url,
42
val statusCode: HttpStatusCode,
43
val requestTime: GMTDate,
44
val responseTime: GMTDate,
45
val version: HttpProtocolVersion,
46
val expires: GMTDate,
47
val vary: Map<String, String>,
48
val varyKeys: Set<String>,
49
val body: ByteArray,
50
val headers: Headers
51
) {
52
fun isStale(): Boolean
53
fun age(): Long
54
}
55
```
56
57
## Built-in Storage Implementations
58
59
### UnlimitedCacheStorage
60
61
Default storage implementation that caches all responses in memory without size limits.
62
63
```kotlin { .api }
64
class UnlimitedCacheStorage : HttpCacheStorage {
65
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry?
66
override suspend fun findAll(url: Url): Set<HttpCacheEntry>
67
override suspend fun store(url: Url, data: HttpCacheEntry)
68
}
69
```
70
71
### DisabledCacheStorage
72
73
No-op storage implementation that disables caching completely.
74
75
```kotlin { .api }
76
object DisabledCacheStorage : HttpCacheStorage {
77
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry? = null
78
override suspend fun findAll(url: Url): Set<HttpCacheEntry> = emptySet()
79
override suspend fun store(url: Url, data: HttpCacheEntry) = Unit
80
}
81
```
82
83
## Basic Usage
84
85
### Simple HTTP Caching
86
87
```kotlin
88
val client = HttpClient {
89
install(HttpCache)
90
}
91
92
// First request - response is cached
93
val response1 = client.get("https://api.example.com/data")
94
val data1 = response1.bodyAsText()
95
96
// Second request - served from cache if still valid
97
val response2 = client.get("https://api.example.com/data")
98
val data2 = response2.bodyAsText() // Same as data1, served from cache
99
100
client.close()
101
```
102
103
### Custom Storage Configuration
104
105
```kotlin
106
val client = HttpClient {
107
install(HttpCache) {
108
publicStorage = UnlimitedCacheStorage()
109
privateStorage = UnlimitedCacheStorage()
110
}
111
}
112
```
113
114
### Disabling Cache
115
116
```kotlin
117
val client = HttpClient {
118
install(HttpCache) {
119
publicStorage = DisabledCacheStorage
120
privateStorage = DisabledCacheStorage
121
}
122
}
123
```
124
125
## Cache Control Headers
126
127
### Server-Side Cache Control
128
129
The cache respects standard HTTP cache control headers sent by servers:
130
131
- `Cache-Control: max-age=3600` - Cache for 1 hour
132
- `Cache-Control: no-cache` - Revalidate on each request
133
- `Cache-Control: no-store` - Don't cache at all
134
- `Cache-Control: private` - Cache only in private storage
135
- `Cache-Control: public` - Cache in public storage
136
- `Expires` - Absolute expiration date
137
- `ETag` - Entity tag for conditional requests
138
- `Last-Modified` - Last modification date for conditional requests
139
140
```kotlin
141
val client = HttpClient {
142
install(HttpCache)
143
}
144
145
// Server response with cache headers:
146
// Cache-Control: max-age=300, public
147
// ETag: "abc123"
148
val response = client.get("https://api.example.com/data")
149
150
// Subsequent request within 5 minutes will be served from cache
151
val cachedResponse = client.get("https://api.example.com/data")
152
```
153
154
### Client-Side Cache Control
155
156
Control caching behavior on individual requests:
157
158
```kotlin
159
val client = HttpClient {
160
install(HttpCache)
161
}
162
163
// Force fresh request (bypass cache)
164
val freshResponse = client.get("https://api.example.com/data") {
165
header("Cache-Control", "no-cache")
166
}
167
168
// Only use cache if available
169
val cachedOnlyResponse = client.get("https://api.example.com/data") {
170
header("Cache-Control", "only-if-cached")
171
}
172
173
// Set maximum acceptable age
174
val maxStaleResponse = client.get("https://api.example.com/data") {
175
header("Cache-Control", "max-stale=60") // Accept cache up to 1 minute stale
176
}
177
```
178
179
## Conditional Requests
180
181
### ETag Validation
182
183
```kotlin
184
val client = HttpClient {
185
install(HttpCache)
186
}
187
188
// First request stores ETag
189
val response1 = client.get("https://api.example.com/resource")
190
191
// Subsequent request includes If-None-Match header
192
// Server returns 304 Not Modified if unchanged
193
val response2 = client.get("https://api.example.com/resource")
194
// Automatically handled by cache plugin
195
```
196
197
### Last-Modified Validation
198
199
```kotlin
200
val client = HttpClient {
201
install(HttpCache)
202
}
203
204
// First request stores Last-Modified date
205
val response1 = client.get("https://api.example.com/document")
206
207
// Subsequent request includes If-Modified-Since header
208
val response2 = client.get("https://api.example.com/document")
209
// Returns cached version if not modified
210
```
211
212
## Advanced Caching Features
213
214
### Vary Header Handling
215
216
The cache properly handles `Vary` headers to store different versions of responses based on request headers:
217
218
```kotlin
219
val client = HttpClient {
220
install(HttpCache)
221
}
222
223
// Server responds with: Vary: Accept-Language, Accept-Encoding
224
val englishResponse = client.get("https://api.example.com/content") {
225
header("Accept-Language", "en-US")
226
}
227
228
val frenchResponse = client.get("https://api.example.com/content") {
229
header("Accept-Language", "fr-FR")
230
}
231
232
// Different cached versions for different languages
233
```
234
235
### Cache Invalidation
236
237
```kotlin
238
class InvalidatingCacheStorage : HttpCacheStorage {
239
private val storage = UnlimitedCacheStorage()
240
241
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry? {
242
val entry = storage.find(url, vary)
243
return if (entry?.isStale() == true) {
244
null // Treat stale entries as cache miss
245
} else {
246
entry
247
}
248
}
249
250
override suspend fun findAll(url: Url): Set<HttpCacheEntry> {
251
return storage.findAll(url).filterNot { it.isStale() }.toSet()
252
}
253
254
override suspend fun store(url: Url, data: HttpCacheEntry) {
255
storage.store(url, data)
256
}
257
}
258
```
259
260
## Custom Storage Implementations
261
262
### Size-Limited Cache Storage
263
264
```kotlin
265
class LimitedSizeCacheStorage(
266
private val maxSizeBytes: Long
267
) : HttpCacheStorage {
268
private val cache = mutableMapOf<String, HttpCacheEntry>()
269
private var currentSize = 0L
270
271
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry? {
272
val key = generateKey(url, vary)
273
return cache[key]?.takeUnless { it.isStale() }
274
}
275
276
override suspend fun findAll(url: Url): Set<HttpCacheEntry> {
277
return cache.values.filter {
278
it.url.toString().startsWith(url.toString()) && !it.isStale()
279
}.toSet()
280
}
281
282
override suspend fun store(url: Url, data: HttpCacheEntry) {
283
val key = generateKey(url, data.vary)
284
val entrySize = data.body.size.toLong()
285
286
// Evict entries if necessary
287
while (currentSize + entrySize > maxSizeBytes && cache.isNotEmpty()) {
288
evictLeastRecentlyUsed()
289
}
290
291
if (entrySize <= maxSizeBytes) {
292
cache[key] = data
293
currentSize += entrySize
294
}
295
}
296
297
private fun generateKey(url: Url, vary: Map<String, String>): String {
298
return "${url}:${vary.entries.sortedBy { it.key }.joinToString(",") { "${it.key}=${it.value}" }}"
299
}
300
301
private fun evictLeastRecentlyUsed() {
302
// Implementation for LRU eviction
303
val oldestEntry = cache.values.minByOrNull { it.responseTime }
304
if (oldestEntry != null) {
305
cache.entries.removeAll { it.value == oldestEntry }
306
currentSize -= oldestEntry.body.size
307
}
308
}
309
}
310
```
311
312
### Persistent Cache Storage
313
314
```kotlin
315
class FileCacheStorage(
316
private val cacheDirectory: File
317
) : HttpCacheStorage {
318
319
init {
320
cacheDirectory.mkdirs()
321
}
322
323
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry? {
324
val cacheFile = getCacheFile(url, vary)
325
return if (cacheFile.exists()) {
326
try {
327
deserializeCacheEntry(cacheFile.readBytes())
328
} catch (e: Exception) {
329
null // Corrupted cache entry
330
}
331
} else {
332
null
333
}
334
}
335
336
override suspend fun findAll(url: Url): Set<HttpCacheEntry> {
337
return cacheDirectory.listFiles()
338
?.mapNotNull { file ->
339
try {
340
val entry = deserializeCacheEntry(file.readBytes())
341
if (entry.url.toString().startsWith(url.toString())) entry else null
342
} catch (e: Exception) {
343
null
344
}
345
}?.toSet() ?: emptySet()
346
}
347
348
override suspend fun store(url: Url, data: HttpCacheEntry) {
349
val cacheFile = getCacheFile(url, data.vary)
350
val serializedData = serializeCacheEntry(data)
351
cacheFile.writeBytes(serializedData)
352
}
353
354
private fun getCacheFile(url: Url, vary: Map<String, String>): File {
355
val filename = generateCacheFileName(url, vary)
356
return File(cacheDirectory, filename)
357
}
358
359
private fun generateCacheFileName(url: Url, vary: Map<String, String>): String {
360
// Generate unique filename based on URL and vary parameters
361
val urlHash = url.toString().hashCode()
362
val varyHash = vary.hashCode()
363
return "${urlHash}_${varyHash}.cache"
364
}
365
366
private fun serializeCacheEntry(entry: HttpCacheEntry): ByteArray {
367
// Implement serialization (JSON, protobuf, etc.)
368
TODO("Implement serialization")
369
}
370
371
private fun deserializeCacheEntry(data: ByteArray): HttpCacheEntry {
372
// Implement deserialization
373
TODO("Implement deserialization")
374
}
375
}
376
```
377
378
### Redis Cache Storage
379
380
```kotlin
381
class RedisCacheStorage(
382
private val redisClient: RedisClient,
383
private val keyPrefix: String = "ktor-cache:"
384
) : HttpCacheStorage {
385
386
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry? {
387
val key = generateRedisKey(url, vary)
388
val serializedEntry = redisClient.get(key) ?: return null
389
390
return try {
391
deserializeCacheEntry(serializedEntry)
392
} catch (e: Exception) {
393
null
394
}
395
}
396
397
override suspend fun findAll(url: Url): Set<HttpCacheEntry> {
398
val pattern = "$keyPrefix${url.encodedPath}*"
399
val keys = redisClient.keys(pattern)
400
401
return keys.mapNotNull { key ->
402
try {
403
redisClient.get(key)?.let { deserializeCacheEntry(it) }
404
} catch (e: Exception) {
405
null
406
}
407
}.toSet()
408
}
409
410
override suspend fun store(url: Url, data: HttpCacheEntry) {
411
val key = generateRedisKey(url, data.vary)
412
val serializedEntry = serializeCacheEntry(data)
413
414
// Set with TTL based on cache entry expiration
415
val ttlSeconds = (data.expires.timestamp - Clock.System.now().epochSeconds).coerceAtLeast(0)
416
redisClient.setex(key, ttlSeconds.toInt(), serializedEntry)
417
}
418
419
private fun generateRedisKey(url: Url, vary: Map<String, String>): String {
420
val varyString = vary.entries.sortedBy { it.key }.joinToString(",") { "${it.key}=${it.value}" }
421
return "$keyPrefix${url}:$varyString"
422
}
423
424
private fun serializeCacheEntry(entry: HttpCacheEntry): String {
425
// Implement JSON or other serialization
426
TODO("Implement serialization")
427
}
428
429
private fun deserializeCacheEntry(data: String): HttpCacheEntry {
430
// Implement deserialization
431
TODO("Implement deserialization")
432
}
433
}
434
```
435
436
## Cache Debugging and Monitoring
437
438
### Cache Hit/Miss Logging
439
440
```kotlin
441
class LoggingCacheStorage(
442
private val delegate: HttpCacheStorage,
443
private val logger: Logger
444
) : HttpCacheStorage by delegate {
445
446
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry? {
447
val entry = delegate.find(url, vary)
448
if (entry != null) {
449
logger.info("Cache HIT for $url")
450
} else {
451
logger.info("Cache MISS for $url")
452
}
453
return entry
454
}
455
456
override suspend fun store(url: Url, data: HttpCacheEntry) {
457
logger.info("Caching response for $url (size: ${data.body.size} bytes)")
458
delegate.store(url, data)
459
}
460
}
461
```
462
463
### Cache Statistics
464
465
```kotlin
466
class StatisticsCacheStorage(
467
private val delegate: HttpCacheStorage
468
) : HttpCacheStorage by delegate {
469
private var hitCount = AtomicLong(0)
470
private var missCount = AtomicLong(0)
471
private var storeCount = AtomicLong(0)
472
473
val hitRatio: Double get() = hitCount.get().toDouble() / (hitCount.get() + missCount.get())
474
475
override suspend fun find(url: Url, vary: Map<String, String>): HttpCacheEntry? {
476
val entry = delegate.find(url, vary)
477
if (entry != null) {
478
hitCount.incrementAndGet()
479
} else {
480
missCount.incrementAndGet()
481
}
482
return entry
483
}
484
485
override suspend fun store(url: Url, data: HttpCacheEntry) {
486
storeCount.incrementAndGet()
487
delegate.store(url, data)
488
}
489
490
fun getStatistics(): CacheStatistics {
491
return CacheStatistics(
492
hits = hitCount.get(),
493
misses = missCount.get(),
494
stores = storeCount.get(),
495
hitRatio = hitRatio
496
)
497
}
498
}
499
500
data class CacheStatistics(
501
val hits: Long,
502
val misses: Long,
503
val stores: Long,
504
val hitRatio: Double
505
)
506
```
507
508
## Cache Configuration Best Practices
509
510
### Production Cache Setup
511
512
```kotlin
513
val client = HttpClient {
514
install(HttpCache) {
515
// Use separate storages for public and private content
516
publicStorage = LimitedSizeCacheStorage(maxSizeBytes = 50 * 1024 * 1024) // 50MB
517
privateStorage = FileCacheStorage(File("cache/private"))
518
}
519
}
520
```
521
522
### Development Cache Setup
523
524
```kotlin
525
val client = HttpClient {
526
install(HttpCache) {
527
if (developmentMode) {
528
// Disable caching in development
529
publicStorage = DisabledCacheStorage
530
privateStorage = DisabledCacheStorage
531
} else {
532
publicStorage = UnlimitedCacheStorage()
533
privateStorage = UnlimitedCacheStorage()
534
}
535
}
536
}
537
```
538
539
## Best Practices
540
541
1. **Choose appropriate storage**: Use memory storage for short-lived applications, persistent storage for long-running ones
542
2. **Set size limits**: Implement size limits to prevent memory issues
543
3. **Respect cache headers**: Always honor server-sent cache control headers
544
4. **Handle stale data**: Implement proper handling of stale cache entries
545
5. **Monitor cache performance**: Track hit/miss ratios to optimize cache effectiveness
546
6. **Secure cached data**: Be careful with sensitive data in shared cache storage
547
7. **Clean up expired entries**: Implement cleanup mechanisms for persistent storage
548
8. **Handle cache invalidation**: Provide mechanisms to invalidate specific cache entries when needed
549
9. **Test cache behavior**: Test your application with and without caching enabled
550
10. **Consider network conditions**: Adjust cache strategies based on network reliability