
Annotation-driven caching for Retrofit, OkHttp and Ktor: safely cache POST/GraphQL queries, auto-invalidate on mutations, dynamic template key resolution, converter-agnostic raw-byte storage.
Retrostash is an annotation-driven caching layer for Retrofit, OkHttp, and Ktor. It solves two
pain points in Kotlin networking: caching non-idempotent queries (like POST searches or GraphQL)
and automatically invalidating cached data when mutations occur. Available as a Kotlin Multiplatform
library targeting Android, JVM, and iOS.
| Android | iOS |
|---|---|
![]() |
![]() |
| Desktop | Web (wasmJs) |
![]() |
![]() |
Sample app
:composeAppruns on Android, JVM desktop, iOS, and wasmJs (browser). Switch between Ktor, OkHttp, and Retrofit transports via the segmented tab.🏠 Project site · 🌐 Live playground · 📚 API docs · 📦 APK + Web bundle
@Path, @Query, and @Body parameters.Cache(...) (see Caching strategy).Retrostash intercepts the raw RequestBody (OkHttp) or HttpRequestBuilder attributes (Ktor). Key
resolution works with plain Kotlin objects, Maps, Arrays, JSON bytes — no Gson/Moshi/
kotlinx.serialization lock-in.
| Module | Targets | Purpose |
|---|---|---|
retrostash-core |
android, jvm, iosX64, iosArm64, iosSimulatorArm64 | Engine, key resolver, in-memory store |
retrostash-annotations |
android, jvm, ios* |
@CacheQuery, @CacheMutate
|
retrostash-ktor |
android, jvm, ios* | Ktor HttpClient plugin |
retrostash-okhttp |
android, jvm | OkHttp interceptor + Retrofit metadata extractor |
Primary surface:
@CacheQuery(key = "...", tags = [...])@CacheMutate(invalidate = [...], invalidateTags = [...])RetrostashStore, InMemoryRetrostashStore, RetrostashEngine (core)RetrostashPlugin, retrostashQuery, retrostashMutate (ktor)RetrostashOkHttpBridge, RetrostashOkHttpAndroid (okhttp)// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}// module build.gradle.kts
dependencies {
// Pick ONE transport. Each pulls retrostash-core + retrostash-annotations transitively
// so you don't add them yourself.
implementation("dev.logickoder:retrostash-okhttp:0.0.13")
// or
implementation("dev.logickoder:retrostash-ktor:0.0.13")
}Need both transports in one project (rare — usually one HTTP stack per app)? Add both retrostash-okhttp and retrostash-ktor. Don't add retrostash-core or retrostash-annotations directly — they come along for the ride.
In Xcode: File → Add Packages… → enter https://github.com/logickoder/retrostash and pick the
version. The Retrostash product bundles core + annotations + Ktor plugin as a single XCFramework.
import Retrostashinterface UserApi {
@CacheQuery("users/{id}?tenant={tenant}")
@POST("users/{id}")
suspend fun getUser(
@Path("id") id: String,
@Body req: UserRequest,
): UserResponse
@CacheMutate(invalidate = ["users/{id}?tenant={tenant}"])
@POST("users/{id}/update")
suspend fun updateUser(
@Path("id") id: String,
@Body req: UpdateUserRequest,
): UpdateUserResponse
}val okHttpBuilder = OkHttpClient.Builder()
val bridge = RetrostashOkHttpAndroid.install(
builder = okHttpBuilder,
context = appContext,
config = RetrostashOkHttpConfig(logger = { Log.d("Retrostash", it) }),
)
val okHttpClient = okHttpBuilder.build()
val sameBridge = RetrostashOkHttpBridge.from(okHttpClient)One cache layer. Don't pass
cache(...)to theOkHttpClient.Builderunless you have a specific reason — Retrostash's annotation-driven cache and OkHttp's HTTP disk cache do not share an invalidation path. See Caching strategy.
JVM (non-Android) consumers construct RetrostashOkHttpBridge directly with their own
RetrostashStore impl — no Context needed.
val store = InMemoryRetrostashStore()
val client = HttpClient {
install(RetrostashPlugin) {
this.store = store
timeoutMs = 250
logger = { println(it) }
}
}
client.get("https://api.example.com/feed/7") {
retrostashQuery(
scopeName = "FeedApi",
template = "feed/{id}",
bindings = mapOf("id" to "7"),
maxAgeMs = 60_000L,
)
}
client.post("https://api.example.com/feed/7") {
retrostashMutate(
scopeName = "FeedApi",
invalidateTemplates = listOf("feed/7"),
bindings = mapOf("id" to "7"),
)
}Templates use {placeholder} syntax. Placeholder sources:
@Path("name")@Query("name")@Body
If any placeholder cannot be resolved, the key is treated as unresolved and the cache action is skipped safely.
When using @CacheMutate, include every related query template in invalidate, including POST-based query templates if you use @CacheQuery on POST endpoints.
RetrostashOkHttpAndroid.clear(appContext)
// or for any RetrostashStore:
store.clear()Direct cache control lives on a dedicated cache accessor on each transport — bridge.cache
(OkHttp) and runtime.cache (Ktor). Same conceptual surface, ergonomics tuned to the transport.
| Verb | OkHttp (blocking, Class<*> scope) |
Ktor (suspend, String scope) |
|---|---|---|
| Read | bridge.cache.peekQuery(apiClass, template, bindings) |
runtime.cache.peekQuery(scopeName, template, bindings, bodyBytes?) |
| Write | bridge.cache.updateQuery(apiClass, template, bindings, payload, contentType?, maxAgeMs?, tags?) |
runtime.cache.updateQuery(scopeName, template, bindings, payload, maxAgeMs?, tags?, bodyBytes?) |
| Invalidate (resolved) | bridge.cache.invalidateQuery(apiClass, template, bindings) |
runtime.cache.invalidateQuery(scopeName, template, bindings, bodyBytes?) |
| Invalidate (raw key) | bridge.cache.invalidateQueryKey(key) |
runtime.cache.invalidateQueryKey(key) |
| Invalidate by tag |
bridge.cache.invalidateTag(tag) / invalidateTags(vararg)
|
runtime.cache.invalidateTag(tag) / invalidateTags(list)
|
| Clear all | bridge.cache.clearAll() |
runtime.cache.clearAll() |
OkHttp methods block (each call wraps runBlocking internally — Android-friendly). Ktor
methods are suspend — call from any coroutine.
bindings is a Map<String, Any?> of placeholder name → value. These match what @Path
and @Query parameters provide on annotated endpoints. For most cache calls you supply this
and nothing else.
bodyBytes is the JSON-encoded request body. Used only as a fallback when a
placeholder isn't in bindings and must be looked up by JSON field name (Retrostash uses
Utf8JsonLookup). Most cache calls leave it null.
Example: a @CacheQuery("posts/{postId}") on @POST with @Body PostRequest(postId = 1337)
caches under a key resolved from the body. To peek that entry from outside the request flow,
you must supply the same body bytes:
val req = PostRequest(postId = 1337)
val bodyBytes = Json.encodeToString(req).encodeToByteArray()
bridge.cache.peekQuery(PostApi::class.java, "posts/{postId}", emptyMap(), /* not OkHttp's signature */)
// OkHttp's bridge.cache currently doesn't accept bodyBytes — pass placeholders via bindings.
// Ktor's runtime.cache does accept bodyBytes for parity with the request flow.Retrostash is converter-agnostic — it stores and returns raw bytes. You bring the bytes:
String: payload.encodeToByteArray().Response<ResponseBody>: response.body()?.bytes().Response<MyDto> (typed) — re-serialize:
// kotlinx.serialization
val bytes = Json.encodeToString(dto).encodeToByteArray()
// Moshi
val bytes = moshi.adapter(MyDto::class.java).toJson(dto).encodeToByteArray()
// Gson
val bytes = gson.toJson(dto).toByteArray()peekQuery returns the body bytes (envelope unwrapped on OkHttp). Decode with the same
serializer you used to encode:
val raw = bridge.cache.peekQuery(UserApi::class.java, "users/{id}", mapOf("id" to "42"))
?: return // not cached
val user: UserDto = Json.decodeFromString(raw.decodeToString())Coupling to one serializer (kotlinx, Moshi, Gson) would lock every consumer in. The byte boundary keeps them interchangeable. Recipes are shipped (above); auto-serialization is not.
suspend fun toggleLike(article: Article) {
// 1. Optimistically update the cached entry the UI reads from
val newState = article.copy(liked = !article.liked, likeCount = article.likeCount + if (article.liked) -1 else 1)
val payload = Json.encodeToString(newState).encodeToByteArray()
bridge.cache.updateQuery(
apiClass = LikeApi::class.java,
template = "like_status/{guid}",
bindings = mapOf("guid" to article.guid),
payload = payload,
maxAgeMs = 60_000L,
)
// 2. Fire the network mutation; on 2xx, @CacheMutate clears + refetches naturally
val result = runCatching { likeApi.toggleLike(article.guid) }
if (result.isFailure) {
// 3. Roll back: re-write the original
val rollback = Json.encodeToString(article).encodeToByteArray()
bridge.cache.updateQuery(
apiClass = LikeApi::class.java,
template = "like_status/{guid}",
bindings = mapOf("guid" to article.guid),
payload = rollback,
maxAgeMs = 60_000L,
)
}
}updateQuery writes the new payload but preserves existing entry metadata when an arg is
omitted (null). Pass an explicit non-null value to override.
| Param |
null (default) |
Explicit value |
|---|---|---|
contentType (OkHttp) |
Keep existing envelope content-type, fall back to "application/json" if no entry. |
Replace. |
maxAgeMs |
Keep existing TTL. Fall back to 0 (no expiry) for new entries. |
Replace. 0L = no expiry. |
tags |
Keep existing tags. Fall back to empty for new entries. |
emptyList() = clear; non-empty = resolve templates and replace. |
The createdAt timestamp resets on every patch — a new write restarts the freshness window.
This means optimistic UI updates are tag-safe by default:
// Original: written by @CacheQuery interceptor with tags = ["article:{id}"]
// Now: optimistic patch — payload changes, tags survive
bridge.cache.updateQuery(
CommentApi::class.java,
"comment:{container_id}",
mapOf("container_id" to articleId),
payload = newJsonBytes,
// contentType, maxAgeMs, tags all null → preserved
)
// invalidateTag("article:$articleId") still finds + clears this entry.updateQuery is served on every
subsequent peekQuery until the next mutation/invalidation. Wrong bytes = lying cache.updateQuery wraps payloads in a synthetic 200 OK
envelope. If consumer code branches on status code, it will always see 200 for cache-hit
entries you wrote.ETag header.Cache(...). Same caveat as elsewhere: Retrostash invalidation
doesn't reach OkHttp's HTTP cache. See Caching strategy.Applies to the OkHttp / Retrofit adapter. Ktor users can skip — HttpClient ships no built-in
HTTP disk cache, so layering doesn't apply there.
Retrostash owns its own annotation-driven cache (@CacheQuery, @CacheMutate, tags). Treat
Retrostash as the cache. Don't pass cache(...) to your OkHttpClient.Builder unless you
have a specific reason and accept the trade-off below.
// Recommended
val okHttpBuilder = OkHttpClient.Builder() // no .cache(...)
RetrostashOkHttpAndroid.install(builder = okHttpBuilder, context = appContext)OkHttp's HTTP disk cache obeys origin Cache-Control headers — separate machinery from
Retrostash's store. Retrostash invalidation (@CacheMutate, bridge.cache.invalidateTag,
bridge.cache.invalidateQuery, bridge.cache.invalidateQueryKey) does not evict OkHttp
HTTP cache entries. Treat OkHttp's HTTP cache like a CDN you don't control — it serves until
its origin TTL expires. After Retrostash invalidates, the next GET can still hit OkHttp's HTTP
cache; you'll see X-Retrostash-Source: okhttp-cache on the response.
If you want OkHttp's HTTP cache for If-None-Match / 304 Not Modified revalidation on the
cold path, that's a fine reason — just know:
Cache-Control headers rule (Retrostash no longer rewrites them).Cache-Control: no-store so OkHttp doesn't cache mutation responses.┌─────────────────────────────┐
│ Retrostash store │ ← @CacheQuery / @CacheMutate / tags
│ (annotation-driven) │ Authoritative for Retrostash invalidation.
└──────────────┬──────────────┘
│ miss
┌──────────────▼──────────────┐
│ OkHttp HTTP cache (Cache) │ ← Origin-Cache-Control driven.
│ (optional, header-driven) │ Retrostash never touches it.
└──────────────┬──────────────┘
│ miss
┌──────────────▼──────────────┐
│ Network │
└─────────────────────────────┘
A single domain object (an article, a user, a workspace) often fans out across unrelated APIs that each chose their own identifier shape. Tags let those APIs share a logical group without forcing the consumer to know every key template.
Declare a tag on each @CacheQuery. Templates use the same {placeholder} syntax as the key and
resolve from the same bindings / body:
@CacheQuery(key = "article:{guid}", tags = ["article:{guid}"])
@GET("article")
suspend fun getArticle(@Query("guid") guid: String): Response<String>
@CacheQuery(key = "like_status:{hostName}:{contentUri}", tags = ["article:{contentUri}"])
@POST("get_like_data")
suspend fun getLikeStatus(@Body request: LikeRequest): Response<List<LikeResponse>>
@CacheQuery(key = "email_alert:{conceptId}", tags = ["article:{conceptId}"])
@GET("checksubscription")
suspend fun getAlertStatus(@Query("conceptId") id: String): Response<EmailAlertResponse>Refresh the article from one place — pass every identifier the article carries:
class ArticleRepository(private val bridge: RetrostashOkHttpBridge) {
fun invalidateArticle(article: Article) {
bridge.cache.invalidateTags(
"article:${article.guid}",
"article:${article.conceptId}",
"article:${article.contentUri}",
)
}
}Adding a new article-related API later is a one-line annotation change — the refresh call site stays the same.
A mutation can also clear by tag declaratively:
@CacheMutate(invalidateTags = ["article:{conceptId}"])
@POST("submit_comment")
suspend fun submitComment(@Body req: CommentRequest): Response<CommentResponse>Ktor users have the same surface: tags on retrostashQuery, invalidateTags on
retrostashMutate, and runtime.cache.invalidateTags(listOf(...)) for imperative refresh.
| Old (0.0.4) | New (0.0.5) |
|---|---|
Retrostash.install(builder, context) |
RetrostashOkHttpAndroid.install(builder, context) |
Retrostash.from(client) |
RetrostashOkHttpBridge.from(client) |
Retrostash.clear(context) |
RetrostashOkHttpAndroid.clear(context) |
RetrostashConfig |
RetrostashOkHttpConfig (OkHttp) or RetrostashConfig (Ktor) |
PostResponseCacheStore |
RetrostashStore + InMemoryRetrostashStore / AndroidRetrostashStore
|
NetworkCachePolicyInterceptor, CacheControlInterceptor
|
merged into RetrostashOkHttpInterceptor
|
JitPack coords com.github.logickoder:retrostash
|
Maven Central coords dev.logickoder:retrostash-*
|
RetrostashOkHttpAndroid.install to wire them in the
right order automatically.Where did bridge.invalidateQueryKey / bridge.invalidateQuery / bridge.invalidateTag(s) go?
Moved to the dedicated cache accessor as a breaking change in 0.0.8:
// before
bridge.invalidateQueryKey(key)
bridge.invalidateQuery(api, template, bindings)
bridge.invalidateTag(tag)
bridge.invalidateTags("a", "b")
// after
bridge.cache.invalidateQueryKey(key)
bridge.cache.invalidateQuery(api, template, bindings)
bridge.cache.invalidateTag(tag)
bridge.cache.invalidateTags("a", "b")Same shape for runtime.cache.invalidateTag(s) on Ktor. Full API in Cache API.
Why am I still seeing X-Retrostash-Source: okhttp-cache after invalidating?
Your OkHttpClient.Builder has cache(...) set, and OkHttp's HTTP disk cache still has the
entry — Retrostash invalidation only clears Retrostash's store. See
Caching strategy. Easiest fix: drop cache(...) from your builder and let
Retrostash own caching.
Which TTL knob does what?
@CacheQuery(maxAgeSeconds = ...) — TTL for that query in Retrostash's store.RetrostashOkHttpConfig.defaultMaxAgeMs — fallback TTL for Retrostash's store when a
@CacheQuery doesn't declare one.Cache-Control headers — Retrostash no longer rewrites them.Where do I find the API docs?
./gradlew dokkaGenerate → build/dokka/html/index.html.Does Retrostash work without Retrofit?
Yes — use the retrostashQuery / retrostashMutate extensions on Request.Builder (OkHttp) or
HttpRequestBuilder (Ktor). Annotations are optional sugar over the same metadata path.
Can I use a custom store?
Implement RetrostashStore and pass it to RetrostashOkHttpBridge / RetrostashPlugin. The
in-memory and Android disk stores are reference implementations.
Full Dokka-generated reference at logickoder.dev/retrostash/api/. Each module's landing page summarizes its purpose and links to the most-used types. Generate locally with:
./gradlew dokkaGenerate
open build/dokka/html/index.htmlSee CONTRIBUTING.md and development.md for:
./gradlew publishToMavenLocal)./gradlew :retrostash-ktor:assembleRetrostashReleaseXCFramework)Retrostash is an annotation-driven caching layer for Retrofit, OkHttp, and Ktor. It solves two
pain points in Kotlin networking: caching non-idempotent queries (like POST searches or GraphQL)
and automatically invalidating cached data when mutations occur. Available as a Kotlin Multiplatform
library targeting Android, JVM, and iOS.
| Android | iOS |
|---|---|
![]() |
![]() |
| Desktop | Web (wasmJs) |
![]() |
![]() |
Sample app
:composeAppruns on Android, JVM desktop, iOS, and wasmJs (browser). Switch between Ktor, OkHttp, and Retrofit transports via the segmented tab.🏠 Project site · 🌐 Live playground · 📚 API docs · 📦 APK + Web bundle
@Path, @Query, and @Body parameters.Cache(...) (see Caching strategy).Retrostash intercepts the raw RequestBody (OkHttp) or HttpRequestBuilder attributes (Ktor). Key
resolution works with plain Kotlin objects, Maps, Arrays, JSON bytes — no Gson/Moshi/
kotlinx.serialization lock-in.
| Module | Targets | Purpose |
|---|---|---|
retrostash-core |
android, jvm, iosX64, iosArm64, iosSimulatorArm64 | Engine, key resolver, in-memory store |
retrostash-annotations |
android, jvm, ios* |
@CacheQuery, @CacheMutate
|
retrostash-ktor |
android, jvm, ios* | Ktor HttpClient plugin |
retrostash-okhttp |
android, jvm | OkHttp interceptor + Retrofit metadata extractor |
Primary surface:
@CacheQuery(key = "...", tags = [...])@CacheMutate(invalidate = [...], invalidateTags = [...])RetrostashStore, InMemoryRetrostashStore, RetrostashEngine (core)RetrostashPlugin, retrostashQuery, retrostashMutate (ktor)RetrostashOkHttpBridge, RetrostashOkHttpAndroid (okhttp)// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}// module build.gradle.kts
dependencies {
// Pick ONE transport. Each pulls retrostash-core + retrostash-annotations transitively
// so you don't add them yourself.
implementation("dev.logickoder:retrostash-okhttp:0.0.13")
// or
implementation("dev.logickoder:retrostash-ktor:0.0.13")
}Need both transports in one project (rare — usually one HTTP stack per app)? Add both retrostash-okhttp and retrostash-ktor. Don't add retrostash-core or retrostash-annotations directly — they come along for the ride.
In Xcode: File → Add Packages… → enter https://github.com/logickoder/retrostash and pick the
version. The Retrostash product bundles core + annotations + Ktor plugin as a single XCFramework.
import Retrostashinterface UserApi {
@CacheQuery("users/{id}?tenant={tenant}")
@POST("users/{id}")
suspend fun getUser(
@Path("id") id: String,
@Body req: UserRequest,
): UserResponse
@CacheMutate(invalidate = ["users/{id}?tenant={tenant}"])
@POST("users/{id}/update")
suspend fun updateUser(
@Path("id") id: String,
@Body req: UpdateUserRequest,
): UpdateUserResponse
}val okHttpBuilder = OkHttpClient.Builder()
val bridge = RetrostashOkHttpAndroid.install(
builder = okHttpBuilder,
context = appContext,
config = RetrostashOkHttpConfig(logger = { Log.d("Retrostash", it) }),
)
val okHttpClient = okHttpBuilder.build()
val sameBridge = RetrostashOkHttpBridge.from(okHttpClient)One cache layer. Don't pass
cache(...)to theOkHttpClient.Builderunless you have a specific reason — Retrostash's annotation-driven cache and OkHttp's HTTP disk cache do not share an invalidation path. See Caching strategy.
JVM (non-Android) consumers construct RetrostashOkHttpBridge directly with their own
RetrostashStore impl — no Context needed.
val store = InMemoryRetrostashStore()
val client = HttpClient {
install(RetrostashPlugin) {
this.store = store
timeoutMs = 250
logger = { println(it) }
}
}
client.get("https://api.example.com/feed/7") {
retrostashQuery(
scopeName = "FeedApi",
template = "feed/{id}",
bindings = mapOf("id" to "7"),
maxAgeMs = 60_000L,
)
}
client.post("https://api.example.com/feed/7") {
retrostashMutate(
scopeName = "FeedApi",
invalidateTemplates = listOf("feed/7"),
bindings = mapOf("id" to "7"),
)
}Templates use {placeholder} syntax. Placeholder sources:
@Path("name")@Query("name")@Body
If any placeholder cannot be resolved, the key is treated as unresolved and the cache action is skipped safely.
When using @CacheMutate, include every related query template in invalidate, including POST-based query templates if you use @CacheQuery on POST endpoints.
RetrostashOkHttpAndroid.clear(appContext)
// or for any RetrostashStore:
store.clear()Direct cache control lives on a dedicated cache accessor on each transport — bridge.cache
(OkHttp) and runtime.cache (Ktor). Same conceptual surface, ergonomics tuned to the transport.
| Verb | OkHttp (blocking, Class<*> scope) |
Ktor (suspend, String scope) |
|---|---|---|
| Read | bridge.cache.peekQuery(apiClass, template, bindings) |
runtime.cache.peekQuery(scopeName, template, bindings, bodyBytes?) |
| Write | bridge.cache.updateQuery(apiClass, template, bindings, payload, contentType?, maxAgeMs?, tags?) |
runtime.cache.updateQuery(scopeName, template, bindings, payload, maxAgeMs?, tags?, bodyBytes?) |
| Invalidate (resolved) | bridge.cache.invalidateQuery(apiClass, template, bindings) |
runtime.cache.invalidateQuery(scopeName, template, bindings, bodyBytes?) |
| Invalidate (raw key) | bridge.cache.invalidateQueryKey(key) |
runtime.cache.invalidateQueryKey(key) |
| Invalidate by tag |
bridge.cache.invalidateTag(tag) / invalidateTags(vararg)
|
runtime.cache.invalidateTag(tag) / invalidateTags(list)
|
| Clear all | bridge.cache.clearAll() |
runtime.cache.clearAll() |
OkHttp methods block (each call wraps runBlocking internally — Android-friendly). Ktor
methods are suspend — call from any coroutine.
bindings is a Map<String, Any?> of placeholder name → value. These match what @Path
and @Query parameters provide on annotated endpoints. For most cache calls you supply this
and nothing else.
bodyBytes is the JSON-encoded request body. Used only as a fallback when a
placeholder isn't in bindings and must be looked up by JSON field name (Retrostash uses
Utf8JsonLookup). Most cache calls leave it null.
Example: a @CacheQuery("posts/{postId}") on @POST with @Body PostRequest(postId = 1337)
caches under a key resolved from the body. To peek that entry from outside the request flow,
you must supply the same body bytes:
val req = PostRequest(postId = 1337)
val bodyBytes = Json.encodeToString(req).encodeToByteArray()
bridge.cache.peekQuery(PostApi::class.java, "posts/{postId}", emptyMap(), /* not OkHttp's signature */)
// OkHttp's bridge.cache currently doesn't accept bodyBytes — pass placeholders via bindings.
// Ktor's runtime.cache does accept bodyBytes for parity with the request flow.Retrostash is converter-agnostic — it stores and returns raw bytes. You bring the bytes:
String: payload.encodeToByteArray().Response<ResponseBody>: response.body()?.bytes().Response<MyDto> (typed) — re-serialize:
// kotlinx.serialization
val bytes = Json.encodeToString(dto).encodeToByteArray()
// Moshi
val bytes = moshi.adapter(MyDto::class.java).toJson(dto).encodeToByteArray()
// Gson
val bytes = gson.toJson(dto).toByteArray()peekQuery returns the body bytes (envelope unwrapped on OkHttp). Decode with the same
serializer you used to encode:
val raw = bridge.cache.peekQuery(UserApi::class.java, "users/{id}", mapOf("id" to "42"))
?: return // not cached
val user: UserDto = Json.decodeFromString(raw.decodeToString())Coupling to one serializer (kotlinx, Moshi, Gson) would lock every consumer in. The byte boundary keeps them interchangeable. Recipes are shipped (above); auto-serialization is not.
suspend fun toggleLike(article: Article) {
// 1. Optimistically update the cached entry the UI reads from
val newState = article.copy(liked = !article.liked, likeCount = article.likeCount + if (article.liked) -1 else 1)
val payload = Json.encodeToString(newState).encodeToByteArray()
bridge.cache.updateQuery(
apiClass = LikeApi::class.java,
template = "like_status/{guid}",
bindings = mapOf("guid" to article.guid),
payload = payload,
maxAgeMs = 60_000L,
)
// 2. Fire the network mutation; on 2xx, @CacheMutate clears + refetches naturally
val result = runCatching { likeApi.toggleLike(article.guid) }
if (result.isFailure) {
// 3. Roll back: re-write the original
val rollback = Json.encodeToString(article).encodeToByteArray()
bridge.cache.updateQuery(
apiClass = LikeApi::class.java,
template = "like_status/{guid}",
bindings = mapOf("guid" to article.guid),
payload = rollback,
maxAgeMs = 60_000L,
)
}
}updateQuery writes the new payload but preserves existing entry metadata when an arg is
omitted (null). Pass an explicit non-null value to override.
| Param |
null (default) |
Explicit value |
|---|---|---|
contentType (OkHttp) |
Keep existing envelope content-type, fall back to "application/json" if no entry. |
Replace. |
maxAgeMs |
Keep existing TTL. Fall back to 0 (no expiry) for new entries. |
Replace. 0L = no expiry. |
tags |
Keep existing tags. Fall back to empty for new entries. |
emptyList() = clear; non-empty = resolve templates and replace. |
The createdAt timestamp resets on every patch — a new write restarts the freshness window.
This means optimistic UI updates are tag-safe by default:
// Original: written by @CacheQuery interceptor with tags = ["article:{id}"]
// Now: optimistic patch — payload changes, tags survive
bridge.cache.updateQuery(
CommentApi::class.java,
"comment:{container_id}",
mapOf("container_id" to articleId),
payload = newJsonBytes,
// contentType, maxAgeMs, tags all null → preserved
)
// invalidateTag("article:$articleId") still finds + clears this entry.updateQuery is served on every
subsequent peekQuery until the next mutation/invalidation. Wrong bytes = lying cache.updateQuery wraps payloads in a synthetic 200 OK
envelope. If consumer code branches on status code, it will always see 200 for cache-hit
entries you wrote.ETag header.Cache(...). Same caveat as elsewhere: Retrostash invalidation
doesn't reach OkHttp's HTTP cache. See Caching strategy.Applies to the OkHttp / Retrofit adapter. Ktor users can skip — HttpClient ships no built-in
HTTP disk cache, so layering doesn't apply there.
Retrostash owns its own annotation-driven cache (@CacheQuery, @CacheMutate, tags). Treat
Retrostash as the cache. Don't pass cache(...) to your OkHttpClient.Builder unless you
have a specific reason and accept the trade-off below.
// Recommended
val okHttpBuilder = OkHttpClient.Builder() // no .cache(...)
RetrostashOkHttpAndroid.install(builder = okHttpBuilder, context = appContext)OkHttp's HTTP disk cache obeys origin Cache-Control headers — separate machinery from
Retrostash's store. Retrostash invalidation (@CacheMutate, bridge.cache.invalidateTag,
bridge.cache.invalidateQuery, bridge.cache.invalidateQueryKey) does not evict OkHttp
HTTP cache entries. Treat OkHttp's HTTP cache like a CDN you don't control — it serves until
its origin TTL expires. After Retrostash invalidates, the next GET can still hit OkHttp's HTTP
cache; you'll see X-Retrostash-Source: okhttp-cache on the response.
If you want OkHttp's HTTP cache for If-None-Match / 304 Not Modified revalidation on the
cold path, that's a fine reason — just know:
Cache-Control headers rule (Retrostash no longer rewrites them).Cache-Control: no-store so OkHttp doesn't cache mutation responses.┌─────────────────────────────┐
│ Retrostash store │ ← @CacheQuery / @CacheMutate / tags
│ (annotation-driven) │ Authoritative for Retrostash invalidation.
└──────────────┬──────────────┘
│ miss
┌──────────────▼──────────────┐
│ OkHttp HTTP cache (Cache) │ ← Origin-Cache-Control driven.
│ (optional, header-driven) │ Retrostash never touches it.
└──────────────┬──────────────┘
│ miss
┌──────────────▼──────────────┐
│ Network │
└─────────────────────────────┘
A single domain object (an article, a user, a workspace) often fans out across unrelated APIs that each chose their own identifier shape. Tags let those APIs share a logical group without forcing the consumer to know every key template.
Declare a tag on each @CacheQuery. Templates use the same {placeholder} syntax as the key and
resolve from the same bindings / body:
@CacheQuery(key = "article:{guid}", tags = ["article:{guid}"])
@GET("article")
suspend fun getArticle(@Query("guid") guid: String): Response<String>
@CacheQuery(key = "like_status:{hostName}:{contentUri}", tags = ["article:{contentUri}"])
@POST("get_like_data")
suspend fun getLikeStatus(@Body request: LikeRequest): Response<List<LikeResponse>>
@CacheQuery(key = "email_alert:{conceptId}", tags = ["article:{conceptId}"])
@GET("checksubscription")
suspend fun getAlertStatus(@Query("conceptId") id: String): Response<EmailAlertResponse>Refresh the article from one place — pass every identifier the article carries:
class ArticleRepository(private val bridge: RetrostashOkHttpBridge) {
fun invalidateArticle(article: Article) {
bridge.cache.invalidateTags(
"article:${article.guid}",
"article:${article.conceptId}",
"article:${article.contentUri}",
)
}
}Adding a new article-related API later is a one-line annotation change — the refresh call site stays the same.
A mutation can also clear by tag declaratively:
@CacheMutate(invalidateTags = ["article:{conceptId}"])
@POST("submit_comment")
suspend fun submitComment(@Body req: CommentRequest): Response<CommentResponse>Ktor users have the same surface: tags on retrostashQuery, invalidateTags on
retrostashMutate, and runtime.cache.invalidateTags(listOf(...)) for imperative refresh.
| Old (0.0.4) | New (0.0.5) |
|---|---|
Retrostash.install(builder, context) |
RetrostashOkHttpAndroid.install(builder, context) |
Retrostash.from(client) |
RetrostashOkHttpBridge.from(client) |
Retrostash.clear(context) |
RetrostashOkHttpAndroid.clear(context) |
RetrostashConfig |
RetrostashOkHttpConfig (OkHttp) or RetrostashConfig (Ktor) |
PostResponseCacheStore |
RetrostashStore + InMemoryRetrostashStore / AndroidRetrostashStore
|
NetworkCachePolicyInterceptor, CacheControlInterceptor
|
merged into RetrostashOkHttpInterceptor
|
JitPack coords com.github.logickoder:retrostash
|
Maven Central coords dev.logickoder:retrostash-*
|
RetrostashOkHttpAndroid.install to wire them in the
right order automatically.Where did bridge.invalidateQueryKey / bridge.invalidateQuery / bridge.invalidateTag(s) go?
Moved to the dedicated cache accessor as a breaking change in 0.0.8:
// before
bridge.invalidateQueryKey(key)
bridge.invalidateQuery(api, template, bindings)
bridge.invalidateTag(tag)
bridge.invalidateTags("a", "b")
// after
bridge.cache.invalidateQueryKey(key)
bridge.cache.invalidateQuery(api, template, bindings)
bridge.cache.invalidateTag(tag)
bridge.cache.invalidateTags("a", "b")Same shape for runtime.cache.invalidateTag(s) on Ktor. Full API in Cache API.
Why am I still seeing X-Retrostash-Source: okhttp-cache after invalidating?
Your OkHttpClient.Builder has cache(...) set, and OkHttp's HTTP disk cache still has the
entry — Retrostash invalidation only clears Retrostash's store. See
Caching strategy. Easiest fix: drop cache(...) from your builder and let
Retrostash own caching.
Which TTL knob does what?
@CacheQuery(maxAgeSeconds = ...) — TTL for that query in Retrostash's store.RetrostashOkHttpConfig.defaultMaxAgeMs — fallback TTL for Retrostash's store when a
@CacheQuery doesn't declare one.Cache-Control headers — Retrostash no longer rewrites them.Where do I find the API docs?
./gradlew dokkaGenerate → build/dokka/html/index.html.Does Retrostash work without Retrofit?
Yes — use the retrostashQuery / retrostashMutate extensions on Request.Builder (OkHttp) or
HttpRequestBuilder (Ktor). Annotations are optional sugar over the same metadata path.
Can I use a custom store?
Implement RetrostashStore and pass it to RetrostashOkHttpBridge / RetrostashPlugin. The
in-memory and Android disk stores are reference implementations.
Full Dokka-generated reference at logickoder.dev/retrostash/api/. Each module's landing page summarizes its purpose and links to the most-used types. Generate locally with:
./gradlew dokkaGenerate
open build/dokka/html/index.htmlSee CONTRIBUTING.md and development.md for:
./gradlew publishToMavenLocal)./gradlew :retrostash-ktor:assembleRetrostashReleaseXCFramework)