条件付きGETが解決すること
APIを定期取得する処理では、データが変わっていないのに毎回同じJSONを転送し、検証し、DBへ書き直すことがあります。数十件なら目立ちませんが、BOM、CAD属性、商品マスターのように表現が大きくなると、帯域・CPU・書き込み回数が無駄になります。
HTTPの条件付きGETは、クライアントが手元の表現に対応するETagを送り、サーバーに「この版から変わったか」だけを判定してもらう仕組みです。変更がなければサーバーは304 Not Modifiedを返し、表現の本文を再送しません。クライアントは保存済みの本文を再利用します。
重要なのは、304を「空のJSON」と解釈しないことです。304は、以前の200 OKで保存した表現がまだ使えるという制御情報です。
ETagはリソースではなく選択された表現の識別子
ETagは、選択された表現に対する不透明なvalidatorです。たとえば同じ部品リソースでも、Accept: application/jsonとAccept: text/csv、日本語と英語、権限別の項目公開範囲では、返す表現が異なります。したがって、DBのupdated_atだけをそのままETagにすると、出力形式や権限の差を表現できないことがあります。
最小のJSON表現を考えます。
{
"part_id": "P-1001",
"revision": 7,
"name": "取付ブラケット",
"mass_g": 245.5,
"released": true
}
このobjectには、文字列、整数、小数、真偽値という異なるtypeのfieldがあります。ETagを内容から計算するなら、サーバーが実際に送るbytesを確定した後でhash化するのが基本です。keyの順序、空白、Unicodeのescape方法、小数の表記が変わればbytesも変わるため、serialize手順を固定します。
import base64
import hashlib
import json
def json_representation(value: dict) -> tuple[bytes, str]:
body = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
digest = hashlib.sha256(body).digest()
token = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
etag = f'"sha256-{token}"'
return body, etag
ETagの値はクライアントが意味を分解するversion番号ではありません。引用符を含むentity-tag全体を保存し、次のIf-None-Matchへそのまま渡します。DBのversion列をETag生成に使う設計でも、クライアント側で7を推測して組み立てず、レスポンスheaderを正とします。
strong ETagとweak ETag
strong ETagは、GETで観測できる表現dataが変われば値も変わるvalidatorです。weak ETagはW/"..."の形で、多少の差があってもサーバーが再利用可能と判断する表現群を同じ版として扱えます。
If-None-Matchの比較にはweak comparisonが使われるため、weak ETagもcache再検証には利用できます。一方、byte範囲の整合性などbytes単位の一致が必要な用途にはstrong validatorが適します。用途を決めずにW/を付け外ししないことが大切です。
入力から再利用までの流れ
初回取得ではvalidatorがないため、通常のGETを送ります。
GET /api/parts/P-1001 HTTP/1.1
Host: api.example.com
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
ETag: "sha256-abc123"
Cache-Control: private, max-age=0, must-revalidate
Vary: Accept
{"part_id":"P-1001","revision":7,"name":"取付ブラケット","mass_g":245.5,"released":true}
クライアントは本文とETagを同じrecordとして保存します。次回は保存したETagを送ります。
GET /api/parts/P-1001 HTTP/1.1
Host: api.example.com
Accept: application/json
If-None-Match: "sha256-abc123"
表現が変わっていなければ、サーバーは本文なしで返します。
HTTP/1.1 304 Not Modified
ETag: "sha256-abc123"
Cache-Control: private, max-age=0, must-revalidate
Vary: Accept
flowchart TD
A[保存済み本文とETagを読む] --> B[If-None-Match付きGET]
B --> C{応答status}
C -->|200| D[本文を検証してcacheを置換]
C -->|304| E[保存済み本文を再利用]
C -->|その他| F[error policyへ分岐]表現が変わっていれば、サーバーは新しい本文と新しいETagを200で返します。304と200は両方とも正常系ですが、出力dataの取得元が異なります。
cache recordをどう設計するか
cacheは本文だけでなく、その本文を選択した条件とvalidatorを一緒に保持します。SQLiteなら次のような最小tableを作れます。
CREATE TABLE http_cache (
cache_key TEXT PRIMARY KEY,
url TEXT NOT NULL,
accept TEXT NOT NULL,
auth_scope TEXT NOT NULL,
etag TEXT NOT NULL,
content_type TEXT NOT NULL,
body BLOB NOT NULL,
fetched_at TEXT NOT NULL
);
cache_keyはURLだけでなく、表現選択に影響するAcceptや認証scopeを含めて作ります。ただしaccess tokenそのものをkeyやlogへ残してはいけません。利用者IDや権限集合を内部IDへ変換し、公開範囲が同じcacheだけを共有します。
| field | 役割 | 注意点 |
|---|---|---|
url | 取得対象 | query parameterの正規化方針を固定する |
accept | 希望するmedia type | JSONとCSVを同じcacheにしない |
auth_scope | 公開範囲の境界 | tokenやcredentialは保存しない |
etag | 次回のvalidator | 引用符とW/を含めて保存する |
content_type | 本文の型 | JSONとして読む前に確認する |
body | 再利用するbytes | ETagだけ保存して本文を失わない |
fetched_at | 観測時刻 | resourceの更新時刻と混同しない |
Varyは、どのrequest headerの違いがcache keyに影響するかを表します。たとえばVary: Acceptがあるなら、JSONとCSVを同じ保存entryとして再利用してはいけません。独自client cacheでも、サーバーのVaryと同じ発想でkeyを設計します。
Pythonで条件付きGETを実装する
次はPython標準libraryだけで、保存済みETagを送る最小clientです。urllib.requestでは304がHTTPErrorとして渡されるため、status codeを明示的に扱います。
from __future__ import annotations
import json
from dataclasses import dataclass
from urllib.error import HTTPError
from urllib.request import Request, urlopen
@dataclass(frozen=True)
class CachedJson:
etag: str
content_type: str
body: bytes
def fetch_json(url: str, cached: CachedJson | None) -> CachedJson:
headers = {"Accept": "application/json"}
if cached is not None:
headers["If-None-Match"] = cached.etag
request = Request(url, headers=headers, method="GET")
try:
with urlopen(request, timeout=10) as response:
content_type = response.headers.get_content_type()
if content_type != "application/json":
raise ValueError(f"unexpected Content-Type: {content_type}")
body = response.read()
json.loads(body.decode("utf-8"))
etag = response.headers.get("ETag")
if not etag:
raise ValueError("ETag is required by this client contract")
return CachedJson(etag, content_type, body)
except HTTPError as error:
if error.code == 304:
if cached is None:
raise RuntimeError("304 received without a cached body") from error
return cached
raise
実務では200で受け取った本文をvalidationした後、本文とETagを同一transactionで置換します。途中でprocessが落ちて「新ETag・旧本文」という不整合になると、次回304で古い本文を正しいものとして再利用してしまうからです。
JSONのvalidationでは、UTF-8 decodeとsyntaxだけでなく、必要なfield、type、ID形式、単位、null許可、数値範囲も確認します。ETagは内容の意味を保証するschemaではありません。schema versionやJSON Schemaなどの契約は別に必要です。
正常系と異常系を分ける
| 応答 | clientの処理 | 再利用の可否 |
|---|---|---|
200 | 型とschemaを検証し、本文とETagを原子的に保存 | 新本文を使う |
304 | 保存済み本文を読む | 対応する本文がある場合だけ可 |
401 / 403 | credentialまたは権限を見直す | 別利用者のcacheは不可 |
404 / 410 | 削除・非公開化policyに従う | 自動再利用しない |
429 | Retry-After等に従い再試行を制御 | 既定で304扱いしない |
5xx | 障害として記録し、明示したstale policyだけ適用 | 無条件には不可 |
通信失敗や5xxを「変更なし」と見なすのは危険です。stale dataを許す業務なら、最大許容時間と画面表示を契約として決めます。たとえば在庫や承認状態は古い値を使えない一方、公開catalogの説明文は短時間なら許容できるかもしれません。
If-Modified-Sinceとの優先関係
Last-ModifiedとIf-Modified-Sinceでも再検証できますが、時刻の解像度やclockの問題があります。If-None-Matchが同じrequestに含まれる場合、HTTP仕様ではIf-Modified-Sinceを無視し、ETag条件を優先します。両方を独自のAND条件として実装してはいけません。
304に本文を入れない
304はmessage bodyを持ちません。API clientが常にresponse.json()を呼ぶ実装だと、変更なしがparse errorになります。statusを先に分岐し、304では保存済みbodyへ切り替えます。逆に、保存済みbodyがないのに304を受けた場合は契約違反として再取得またはerrorにします。
セキュリティと秘密情報の分離
認証付きAPIでは、cacheの効率より情報境界を優先します。利用者ごとに内容が違うならCache-Control: privateを検討し、保存entryも認証scopeごとに分離します。機密dataを保存してはいけない要件ならno-storeを選び、条件付きGET用の永続cache自体を作りません。
ETagが本文hashだから安全とは限りません。候補dataを推測できる場合、hash値が内容確認のoracleになる可能性があります。また、ETagをaccess token代わりに使ってはいけません。認可は毎回評価し、権限が失効したrequestへ304を返して保存済み機密dataの利用を促さないようにします。
logにはURL、status、転送bytes、cache hitの有無、処理時間を残せますが、Authorization、cookie、本文、個人情報を無条件に記録しません。ETagも機密性を評価してから記録します。
実務で起きやすい失敗
- DB rowが同じなら全形式で同じETagを返し、JSONとCSVを取り違える
Varyを無視し、言語・圧縮・media typeの違う表現を再利用する- user別の表現をURLだけでcacheし、別userへ見せる
304を空dataとしてDBへupsertし、既存recordを消す- ETagだけ更新して本文保存に失敗し、次回の再検証を壊す
- JSONのfield順や小数表記が毎回変わり、意味が同じでもETagが変わる
200、304、401、404、429、5xxを同じretry処理へ流す
testでは、初回200、同一ETagの304、変更後200、cache欠損時の304、別Accept、別権限scope、認可失効を独立caseにします。さらに、本文保存とETag保存の間でprocessを停止させ、transactionによって不整合が残らないことを確認します。
CAD・BOM・データ連携へ再利用する設計
CAD属性やBOMは大きく、変更頻度が低い一方で定期同期されやすいため、条件付きGETと相性があります。ただし、ETagが示す範囲をAPI契約に明記します。部品1件、BOM全体、特定revision、export形式のどれに対応するかが曖昧だと、差分更新の単位を決められません。
将来DBやETLへつなぐなら、取得処理の出力を「本文」と「取得metadata」に分けます。本文は業務tableへ変換し、URL、ETag、取得時刻、status、schema versionは同期管理tableへ保存します。304のときは業務recordを書き直さず、確認時刻だけを更新できます。これにより、再実行しても同じ本文を重複insertせず、変更があったときだけ変換・validation・upsertを実行できます。
まとめ
条件付きGETは、単なる通信量削減ではなく、保存済みdataを安全に再利用するためのinterface契約です。ETagを選択された表現へ結び付け、本文とvalidatorを原子的に保存し、200と304を別の正常系として扱うことが中心になります。
URL、Accept、認証scope、Varyをcache keyへ反映し、失敗応答を勝手に「変更なし」へ変換しない設計にすれば、API pollingからBOM・CAD・DB同期まで同じ考え方を再利用できます。
