Lesson 11 / 14
The Network Client
A network client defines a request, passes it through interceptors, and decodes the incoming response with serialization; the decoded fields build the network tier of the read. The lesson measures the origin of a read of a decoded response and what a cached response and a field that cannot be decoded fall to.
Published Updated
In this lesson
- Separates the duties of the request definition, the interceptor, and serialization in a network client
- Explains that the fields of a decoded response build the network tier of the read
- Measures the origin of a read of a decoded response as network
- Shows that a cached response comes from disk and a field that cannot be decoded falls to default
Contents
In the previous lesson the file was on the disk of the same device. Most data, however, is on another machine and is reached with a request. In this lesson we look at how the response to that request enters the chain: the response arrives as text, it is decoded, and it becomes an answer for the read only as far as it was decoded. The question of the lesson is the same as in the earlier lessons: where does the answer to this read come from?
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
init {
require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
}
fun lookup(key: String): String? = entries[key]
}
class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
val value: String = hit?.lookup(key) ?: fallback
val origin: String = hit?.origin ?: "default"
fun show() {
println("$label: key=$key | value=$value -> $origin")
}
}
fun countOrigins(reads: List<Read>) {
for (name in listOf("memory", "disk", "network", "default")) {
println("$name: ${reads.count { it.origin == name }}")
}
}
Request, Interceptor, Serialization
A network client does three jobs. The request definition writes, as an interface, which address is called with which parameters. The interceptor steps in between the request and the response and does things such as adding a header, writing a log, or storing the response. Serialization turns the text in the response body into the classes of the application.
// Platform code: ProductApi
interface ProductApi {
@GET("product")
suspend fun product(@Query("id") id: String, @Query("lang") lang: String): ProductDto
}
@Serializable
data class ProductDto(
val price: String,
val stock: Int = 0,
val campaign: String? = null,
)
The request definition is written in the interface; @GET gives the address and @Query gives the parameters. The response body is decoded into ProductDto, and a field that is not in the body falls to the default value in the class. The interceptor is also a separate class.
// Platform code: CacheHeaderInterceptor
class CacheHeaderInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
return response.newBuilder()
.header("Cache-Control", "max-age=60")
.build()
}
}
These two pieces are platform code, and here we make no claim about output, duration, or response code. The model builds the rules of the platform; the model is not the platform itself.
In the model, the request is a single line built from an address and parameters. The response is a body in which key=value pairs are separated by semicolons; the decoder extracts a map from this body. A piece that carries no = sign cannot be decoded and does not enter the map.
// Wire.kt — builds the request as one line, decodes the response body into a map
fun requestLine(path: String, query: Map<String, String>): String =
"GET $path?" + query.toSortedMap().entries.joinToString("&") { "${it.key}=${it.value}" }
fun decodeResponse(body: String): Map<String, String> =
body.split(";")
.filter { "=" in it }
.associate { part -> part.substringBefore("=") to part.substringAfter("=") }
The screen reads three values. Each has a fallback value.
// Product.kt — the three values the product screen reads
fun readProduct(chain: List<Tier>): List<Read> = listOf(
Read("price", "product.price", chain, "unknown"),
Read("stock", "product.stock", chain, "0"),
Read("campaign", "campaign.code", chain, "none"),
)
A Decoded Response Is Network
The server returns a body with three fields. The decoder turns the body into a map, and this map is the network tier.
// Main.kt — the decoded response builds the map of the network
fun main() {
println(requestLine("/product", mapOf("id" to "7", "lang" to "en")))
val body = "product.price=125;product.stock=8;campaign.code=SUMMER10"
val network = Tier("network", decodeResponse(body))
val reads = readProduct(listOf(network))
reads.forEach { it.show() }
countOrigins(reads)
}
GET /product?id=7&lang=en price: key=product.price | value=125 -> network stock: key=product.stock | value=8 -> network campaign: key=campaign.code | value=SUMMER10 -> network memory: 0 disk: 0 network: 3 default: 0
All three of the three fields came from the network. This lesson’s origin is network: the fields of the decoded response build the network tier. The network tier is a map that exists after the request has gone and the response has been decoded; text that has not been decoded cannot answer a read.
DL1. The model assumes that the request always succeeds and that the whole response body arrives. A connection being dropped, a timeout, and the server returning an error code are absent from the model; in the model, the network tier is either a map or is never built.
The Bounding Measurement
Two things break the network origin. The first is the interceptor storing the response: if the response to an earlier request remains in a cache on disk, the next read looks at that cache. The second is a field that cannot be decoded: a piece that is in the body but that the decoder could not put into the map does not answer the read.
First the cache. The cache is a disk tier decoded from the body of an earlier request, and it comes before the network in the chain. The body in the cache is stale: the price is 120 and there is no campaign.
// Main.kt — a cached response comes from disk, a field not in the cache comes from the network
fun main() {
val cachedBody = "product.price=120;product.stock=8"
val freshBody = "product.price=125;product.stock=8;campaign.code=SUMMER10"
val cache = Tier("disk", decodeResponse(cachedBody))
val network = Tier("network", decodeResponse(freshBody))
val reads = readProduct(listOf(cache, network))
reads.forEach { it.show() }
countOrigins(reads)
}
price: key=product.price | value=120 -> disk stock: key=product.stock | value=8 -> disk campaign: key=campaign.code | value=SUMMER10 -> network memory: 0 disk: 2 network: 1 default: 0
The price and the stock were read from the cache, that is, from disk. The price is 120; even though the current value on the server is 125, the read did not reach it. The campaign was not in the cache and came from the network. The rule sees the order, not the freshness.
Now the field that cannot be decoded. The body has a product.stock piece, but it has no = sign and no value.
// Main.kt — a field that cannot be decoded does not enter the network map and falls to default
fun main() {
val body = "product.price=125;product.stock;campaign.code=SUMMER10"
val network = Tier("network", decodeResponse(body))
val reads = readProduct(listOf(network))
reads.forEach { it.show() }
countOrigins(reads)
}
price: key=product.price | value=125 -> network stock: key=product.stock | value=0 -> default campaign: key=campaign.code | value=SUMMER10 -> network memory: 0 disk: 0 network: 2 default: 1
The server sent the stock field, but the decoder could not put it into the map. For the read, that field counts as absent, and the answer is the fallback value, 0. The default value rule in serialization on the platform covers only one half of this: if a field is not in the body, the default value in the class is used. A value that is in the body but cannot be decoded raises an error in the default configuration; a separately enabled setting is needed for it to fall to the default.
What the measurement does not say is why the body is malformed, and whether 0 is a correct value for the stock. The rule says only which tier the answer came from.
DL2. The model builds the decoder only for the key=value form. Nested objects, lists, type mismatches, and the ignoring of unknown fields are absent from the model.
Summary
- This lesson’s origin: network. The fields of the decoded response build the network tier, and the read is answered from that tier.
- A network client defines the request, passes it through interceptors, and decodes the response with serialization.
- When a response with three fields is decoded, three of the three reads are network: network 3.
- If a cached response comes before the network in the chain, the read is answered from disk: disk 2, network 1. The price stays at 120, and the rule does not see the 125 on the server.
- A field that cannot be decoded does not enter the network map; that read falls to default: network 2, default 1.
Next Step
In this lesson the cache was only a tier, the decoded form of an old response. Writing the response permanently to disk and making the screen read only the disk is a separate design: the basis of working offline. The next lesson, Caching and Offline Support, measures which origin a refresh that writes from the network to disk brings the read to, and why, when the network goes down, the origin stays the same while the value goes stale.
To keep your progress and take notes, Log in
My notes
Log in to take notes.