Date, Last-ModifiedヘッダがありCache-Controlが不適切な場合OkHttpが思いもよらないCacheをするので注意

(お詫びと訂正)

本件、「思いもよらないCache」ではなく、RFCに示された通りの実装でした。以下に追記します。

RFC7234 4.2.2 Calculating Heuristic Freshness にすべて書いてあるので詳しくはそちらを参照して欲しいですが、サーバが文書の失効に関する明示的な情報を何も返さない場合、キャッシュは他のヘッダを用いてコンテンツの鮮度をヒューリスティック*1に設定するかもしれないとのことです。

RFC7234の当該項目にたとえば代わりに"Last-Modified"を用いることやヒューリスティックな計算結果は最大10%にすることなど何もかも説明がありました。つまりOkHttpがこのようなキャッシュ戦略を取ることは思いもよらないでも何でもなくRFCの勧告通りということです。お詫びして訂正します。

Twitterにて指摘してくださった @hydrakecat さんありがとうございました🙇

 

以下、それを踏まえてお読みください。追記ここまで。

 

 TL;DR

随分長いタイトルになったが、次の条件を満たす場合にOkHttpのCacheが思いもよらない挙動をする場合がある。

  1. HTTPレスポンスに"Date", "Last-Modified"ヘッダが両方指定されている
  2. HTTPレスポンスの"Cache-Control"ヘッダにno-cache, no-store, max-age=0*2 のいずれもが未指定
  3. HTTPレスポンスの"Expires"ヘッダが未指定
  4. "Date" - "Last-Modified" が長い期間(たとえば3日間)

このとき、端的に言うと、思いがけず長い期間Cacheが破棄されずネットワークリクエストすら送られずローカルのCacheを見続ける

 

な…何を言っているのかわからねーと思うが、俺も何をされたのかわからなかったので順番に書いていく。

 

事の発端

そもそもこれに気付いたきっかけが、弊社アプリでCloudFrontに置いた小さな設定ファイルを見に行っている部分がファイルが更新されてもいつまで経っても再DLされず、よく観察してみるとリクエストすら飛んでないことが発覚したためだ。

ファイルにcurl -Iして見ると、たしかに"Cache-Control"ヘッダは設定が漏れている。

しかしOkHttpで"Cache-Control"ヘッダがない場合のキャッシュ戦略がどうなっているか、ソースを読むより方法がない。

 

CacheStrategy

OkHttpのキャッシュはCacheInterceptorという内部Interceptorによって制御されており、その名の通りCacheというクラスによってリクエストURLをキーにしたDiskLruCacheとして実装されている。*3

 

そしてCacheの動きを決める最も重要なクラスがCacheStrategyである。

CacheStrategyはこれからネットワークに向かっていくリクエストとヒットしたキャッシュエントリを受け取って色んなヘッダを見て、クラス名が表すとおりキャッシュ戦略を決定する。

 

まず、CacheStrategy.Factory がレスポンスヘッダをフィールドにマップしていく。

this.sentRequestMillis = cacheResponse.sentRequestAtMillis();
this.receivedResponseMillis = cacheResponse.receivedResponseAtMillis();
Headers headers = cacheResponse.headers();
for (int i = 0, size = headers.size(); i < size; i++) {
String fieldName = headers.name(i);
String value = headers.value(i);
if ("Date".equalsIgnoreCase(fieldName)) {
servedDate = HttpDate.parse(value);
servedDateString = value;
} else if ("Expires".equalsIgnoreCase(fieldName)) {
expires = HttpDate.parse(value);
} else if ("Last-Modified".equalsIgnoreCase(fieldName)) {
lastModified = HttpDate.parse(value);
lastModifiedString = value;
} else if ("ETag".equalsIgnoreCase(fieldName)) {
etag = value;
} else if ("Age".equalsIgnoreCase(fieldName)) {
ageSeconds = HttpHeaders.parseSeconds(value, -1);
}
}

"Date"ヘッダが servedDate に、"Last-Modified"ヘッダが lastModifled に、"Expires"ヘッダが expires にマップされたのを覚えておいて欲しい。

 

次のCacheStrategy#get() とそこから呼ばれる getCandidate() がフィールドにマップされた値を元にキャッシュ戦略を組み立てる心臓部である。

long ageMillis = cacheResponseAge();
long freshMillis = computeFreshnessLifetime();
if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
Response.Builder builder = cacheResponse.newBuilder();
if (ageMillis + minFreshMillis >= freshMillis) {
builder.addHeader("Warning", "110 HttpURLConnection \"Response is stale\"");
}
long oneDayMillis = 24 * 60 * 60 * 1000L;
if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
builder.addHeader("Warning", "113 HttpURLConnection \"Heuristic expiration\"");
}
return new CacheStrategy(null, builder.build());
}

ここが問題のコードだ。

理解のために先に答えを書くと、

  • ageMillis…レスポンスがどのくらい古くなったか
  • freshMillis…キャッシュはどのくらいの期間新鮮か
  • そしてキャッシュを使ってよく、age < fresh ならキャッシュは新鮮だとみなし、ネットワークリクエストすら行わない。
return new CacheStrategy(null, builder.build());

この第一引数がネットワークリクエストだが、これがnullだとCacheInterceptorがネットワークリクエストを一切行わない。

僕が疑問を持っているのがこの計算方法である。

 

cacheResponseAge()

レスポンスのageの計算をしているメソッドだ。この計算方法はRFC 2616 13.2.3 Age Calculationsに明示してある。*4

long apparentReceivedAge = servedDate != null
? Math.max(0, receivedResponseMillis - servedDate.getTime())
: 0;
long receivedAge = ageSeconds != -1
? Math.max(apparentReceivedAge, SECONDS.toMillis(ageSeconds))
: apparentReceivedAge;
long responseDuration = receivedResponseMillis - sentRequestMillis;
long residentDuration = nowMillis - receivedResponseMillis;
return receivedAge + responseDuration + residentDuration;

コンテンツが発行されてから実際に受信に要した時間を計算し、現時点までの経過時間を加算するなどしてageを計算している。*5

ここはいいだろう。

 

computeFreshnessLifetime()

キャッシュが新鮮だと考えられる期間を計算する。コメントを読むと

Returns the number of milliseconds that the response was fresh for, starting from the served date.

とのことで「コンテンツが発行されたときから考えて、レスポンスが新鮮だとみなされるミリ秒」という感じか。

CacheControl responseCaching = cacheResponse.cacheControl();
if (responseCaching.maxAgeSeconds() != -1) {
return SECONDS.toMillis(responseCaching.maxAgeSeconds());
} else if (expires != null) {
long servedMillis = servedDate != null
? servedDate.getTime()
: receivedResponseMillis;
long delta = expires.getTime() - servedMillis;
return delta > 0 ? delta : 0;
} else if (lastModified != null
&& cacheResponse.request().url().query() == null) {
// As recommended by the HTTP RFC and implemented in Firefox, the
// max age of a document should be defaulted to 10% of the
// document's age at the time it was served. Default expiration
// dates aren't used for URIs containing a query.
long servedMillis = servedDate != null
? servedDate.getTime()
: sentRequestMillis;
long delta = servedMillis - lastModified.getTime();
return delta > 0 ? (delta / 10) : 0;
}
return 0;

冒頭の「特定条件」を見直していただければ分かるが、ここでは

} else if (lastModified != null
&& cacheResponse.request().url().query() == null) {

のブロックに入る。

なんとここでは、servedDate(要するにDateヘッダ)から lastModified(Last-Modifiedヘッダ)を引き、10で割って返している。10で割る…

当然だが、"Date"ヘッダと"Last-Modified"が非常に長い期間空いていると(いくら10%とはいえ)非常に長い期間になり、結果的にageを上回ってしまい、この期間ローカルのキャッシュしか見ない状態に陥ってしまう。

 

謎のRFC

コメントには次のようにある

As recommended by the HTTP RFC and implemented in Firefox, the
max age of a document should be defaulted to 10% of the
document's age at the time it was served. Default expiration
dates aren't used for URIs containing a query.

 HTTP RFCが勧告しFirefoxの実装が従っているように、ある文書のmax ageは文書が返されてから経過した時間の10%をデフォルトとすべきだ。

このRFCが何番の何項か分からないので、原文にあたりようがない。したがって現時点でこの仕様と実装が妥当なのか判断がつかない。

これについて何かご存知のかた、是非情報ください。

 

Cache-Controlヘッダが適切に存在する場合

"Cache-Control"ヘッダに

  • max-age=0(というかageより小さければなんでも)
  • no-cache, no-store

がある場合は、いずれもこの謎の計算式に到達せず、キャッシュは古いものとしてネットワークリクエストが送られる。

 

max-ageが計算したageより小さい場合とno-cacheの場合はETag(のための"If-None-Match")ヘッダも正しく使われるし、no-storeの場合はヘッダに従ってキャッシュエントリの保存自体が行われない。

この辺はこれまでに挙げたクラスのソースコードを実際に見て欲しい。行数も少なく、シンプルに書かれているので読みやすい。

 

Date, Last-Modifiedヘッダ

この挙動に触れるまで、"Date"ヘッダと"Last-Modified"ヘッダについてきちんとRFCで読んだことがなかったので改めて読んでみた。

 

RFC2616 14.18 Date

要約すると、メッセージが生成された日時ということだ。(これはそのファイルが置かれた日時という意味ではないので注意)

ざっくりと、このHTTPレスポンスのボディが生成されてクライアントに向けて送出する直前ぐらいまでの時間のようだ。

サーバエラー等やNTPサーバが利用できないような状況を除いて、基本的に全てのHTTPレスポンスに付けなければならない。日本語訳はこちら

 

RFC7232 2.2 Last-Modified

これは提供するコンテンツが最後に更新された日時だ。

更新されたという定義はコンテキストによるようで、それがファイルであればファイルの更新タイムスタンプかもしれないし、XMLJSONの一部ならツリーの一部コンテンツ以下の更新を意味するかもしれない。

条件付きリクエストやキャッシュ鮮度によってネットワークトラフィックを軽減できるため、サーバはLast-Modifiedヘッダを返すべきである。

An origin server SHOULD obtain the Last-Modified value of the
representation as close as possible to the time that it generates the
Date field value for its response.

そしてここは個人的に重要だと思ったので引用したが、Last-Modifiedは可能な限りDateに近づけるようにすべきだと書いてある。正しく運用することでより正確なキャッシュ運用が可能だからだろう。日本語訳はこちら

 

まとめ

まとめと言っても難しいな…特定のヘッダの組み合わせでOkHttpが思いもよらないキャッシュを持つというのを誰かに共有したかった。

敢えて教訓を挙げるならば、キャッシュして欲しくないコンテンツは正しく"Cache-Control"ヘッダを設定しようということだ。

 

弊社ではCloudFrontに置いたファイルを更新してもアプリがそれをいつまで経っても読みに行ってくれず、一体何が起こってるんだ!?とソースコードを読んでこのような挙動を発見した。

同じような運用をしている人は一度そのファイルにcurl -Iしてレスポンスヘッダを確認してみて欲しい。

 

最終的に少し発散してしまったが、computeFreshnessLifetime() の計算式がいかなる根拠によるものか未だに興味があるので、何かご存知のかたは教えてください。宜しくお願い申し上げます。

*1:正確でないかもしれないが経験的にある程度近似しそうなアプローチ

*2:正確に言うと0である必要はなく、後述のage以下ならよい

*3:この辺りに関連することはOkHttpのInterceptorとNetworkInterceptorとCacheの関係 - 怠惰を求めて勤勉に行き着くに書いた

*4:日本語訳はたとえばこちら

*5:本当はより正確な"Age"ヘッダがあればそれを利用する。詳しくはRFC2616参照。