「APIの条件付きGETで無駄な転送を減らす設計|ETag・If-None-Match・304」の内容を表す技術イラスト
UNIT 00LAB NOTES FIG.00 · EYECATCH

APIの条件付きGETで無駄な転送を減らす設計|ETag・If-None-Match・304

APIの条件付きGETで再取得を効率化する設計を解説。ETag、If-None-Match、304、Vary、認証別cache、Python実装、異常系test、CAD・BOM連携まで整理します。

DOC NO.EKP-00-1206
DATE
REVB
READ約 13 分
目次

条件付き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 typeJSONとCSVを同じcacheにしない
auth_scope公開範囲の境界tokenやcredentialは保存しない
etag次回のvalidator引用符とW/を含めて保存する
content_type本文の型JSONとして読む前に確認する
body再利用するbytesETagだけ保存して本文を失わない
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 / 403credentialまたは権限を見直す別利用者のcacheは不可
404 / 410削除・非公開化policyに従う自動再利用しない
429Retry-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同期まで同じ考え方を再利用できます。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次