build · Stage 2

API契約を失敗、再送、進化まで設計する

wire形式だけでなく意味、認可、失敗、冪等性を契約化し、オフライン利用者を壊さず進化させる。

学習時間
300分
難易度
intermediate
更新日
2026-07-30
到達証拠
成果物・説明・判断根拠・転用

到達目標

  1. HTTP意味論、schema、業務不変条件、認可、失敗形式を分離したAPI契約を記述できる

    • version、冪等key、状態遷移、Problem Details、互換性判定を含む実行可能API契約
    • 冪等性と同一応答、schema妥当性と業務妥当性、認可を混同しない5分説明
  2. 応答喪失後の再送で同じ副作用を一回に保ちつつ、同じ応答やexactly-onceを誤って保証しない

    • version、冪等key、状態遷移、Problem Details、互換性判定を含む実行可能API契約
    • 再送と契約進化の障害シナリオを観測で切り分ける回答
  3. source、wire、意味の互換性を区別し、長時間オフラインの利用者へ契約変更を移せる

    • 再送と契約進化の障害シナリオを観測で切り分ける回答
    • 長時間オフライン端末に対する互換な同期と失敗契約

能力の進行

  1. recognize

    HTTP method、表現schema、業務意味、認証、認可、失敗形式を別の契約面として分類できる

    証拠: version、冪等key、状態遷移、Problem Details、互換性判定を含む実行可能API契約

  2. explain

    同じ冪等keyの再送が同じ効果を保っても同じresponseやexactly-onceを意味しない理由を説明できる

    証拠: 冪等性と同一応答、schema妥当性と業務妥当性、認可を混同しない5分説明

  3. apply

    version、再送、状態遷移、Problem Detailsを含むローカル契約fixtureを実装できる

    証拠: version、冪等key、状態遷移、Problem Details、互換性判定を含む実行可能API契約

  4. diagnose

    schema違反、意味違反、認可拒否、version非互換を証拠から切り分けられる

    証拠: 再送と契約進化の障害シナリオを観測で切り分ける回答

  5. lead

    利用者の更新遅延と撤回困難性を考慮し、互換性方針と廃止条件をレビューできる

    証拠: 長時間オフライン端末に対する互換な同期と失敗契約

なぜ重要か

APIはfieldとendpointの一覧ではない。consumerはHTTP methodとstatus、表現の形、fieldの意味、認可、状態遷移、失敗後の次の操作へ依存する。生成したOpenAPI文書やJSON Schemaが妥当でも、別tenantの操作を許したり、同じ再送で副作用を二重化したりすれば契約は壊れている。

長時間オフラインになる端末では、providerが新versionを公開しても旧clientが数週間残る。応答を受け取れなかったclientは、requestが未到達なのか、処理済みで応答だけ失われたのかを区別できない。互換性、冪等性、構造化した失敗を一つの進化方針として設計する必要がある。

メンタルモデル

API契約を六つの面へ分ける。transportはHTTP methodとstatus、structureはJSON Schema、semanticsはfieldと状態遷移の意味、identityは認証主体、authorityはその主体が対象へ行える操作、failureは失敗の分類と回復操作である。一面の検証結果を別の面の保証へ拡張しない。

RFC 9110のmethod意味論は共通の土台だが、業務操作の副作用境界はapplicationが定義する。RFC 9457のProblem Detailsはtype、title、status、detail、instanceという標準memberを定義するが、RFC上これらは一律の必須memberではない。このAPIはtype、title、statusをprofileとして必須にし、detailとinstanceを任意にする。type URIを識別子として使う場合も、本文取得に成功しなければ処理できない設計にはしない。

オフライン操作を副作用と観測へ分ける契約経路

response喪失後のretryで、どの保存済み副作用を再利用し、何を再実行してはいけないか。

  • 初期状態: 作成: clientは操作ID、冪等key、対象version、tenantを永続化する。serverは認証済みprincipal、tenant、route、keyを保存scopeにする。
  • 送信: serverはschema、意味、認証、認可を順に検査する。
  • 適用: keyと操作結果を同じ耐久境界で記録し、状態を一度だけ進める。
  • 応答: 現在時刻やtrace IDを含むresponseは試行ごとに違ってよい。
  • 喪失: responseが届かなくても、clientは同じkeyで安全に再送する。
  • 照会: 保存済み効果を返し、同じ配送や同じresponseを保証したとは主張しない。
  • 進化: 旧versionの利用状況を観測し、source、wire、意味の互換性を別々に判定する。
状態遷移
イベント開始終了判定理由
next作成送信allowed
next送信適用allowed
next適用応答allowed
timer適用喪失allowed
next喪失照会allowed
next応答進化allowed
next照会進化allowed
next喪失適用rejected同じ冪等keyの保存済み効果を再適用してはならない。

冪等keyのscope、耐久化済み効果、試行ごとに変わるresponseを分け、安全なretryと拒否遷移を説明できる。

動く例で考える

現場端末の作業完了を同じkeyで再送する

前提
field-7の端末は最大14日offlineになる。v1とv2は同じCompleteWorkコマンドを受け、tenant-aの主体だけがtenant-aの作業を完了できる。
入力
operation-42をkey-42で初回送信し、response喪失後に同じscopeとpayloadで再送する。同じscopeとkeyでpayloadだけが異なる衝突、同じraw keyを使う別tenant、3種類の契約変更、schema上正しい別tenant requestも与える。
操作
serverはtenant、認証済みprincipal、route、keyとpayload SHA-256を業務効果と共に保存する。同じscopeでfingerprintが違えば409にし、別tenantなら独立した効果として処理する。注入したRFC 3339時計でresponse時刻を生成し、構造検査と認可判定を独立に記録する。
観測
初回と再送のeffect countはともに1、response時刻は異なる。payload衝突ではeffectを増やさず、別tenantでは2件目を作る。v1とv2、3状態遷移、API profileのProblem Details、三軸の互換性分類、schema validだがunauthorizedの反例をJSONへ出力する。
結論
同じ効果を再現できても同じresponseやexactly-once配送を証明したことにはならない。raw keyだけを共有せず認証scopeを分離し、payload取り違えを409にする。旧clientが未知enumを拒否するなら、schema上の追加でも意味的riskである。
python3.13 - <<'PY'
import hashlib
import json

HARNESS = "api_contract_lab_v1"
SUPPORTED_VERSIONS = ["v1", "v2"]
ROUTE = "POST /work-items/{id}:complete"

def structurally_valid(request):
    required = {
        "version",
        "operation_id",
        "idempotency_key",
        "tenant",
        "work_item_id",
        "result",
    }
    return (
        required.issubset(request)
        and request["version"] in SUPPORTED_VERSIONS
        and all(isinstance(request[name], str) and request[name] for name in required)
    )

def authorized(request, principal):
    return request["tenant"] == principal["tenant"]

def request_fingerprint(request):
    payload = {
        name: request[name]
        for name in (
            "version",
            "operation_id",
            "tenant",
            "work_item_id",
            "result",
        )
    }
    canonical = json.dumps(
        payload,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")
    return hashlib.sha256(canonical).hexdigest()

class FixedClock:
    def __init__(self, values):
        self.values = list(values)
        self.index = 0

    def __call__(self):
        if self.index >= len(self.values):
            raise AssertionError("fixed clock exhausted")
        value = self.values[self.index]
        self.index += 1
        return value

class WorkService:
    def __init__(self, clock):
        self.results = {}
        self.effect_count = 0
        self.transitions = []
        self.clock = clock

    def complete(self, request, principal, route):
        if not structurally_valid(request) or not authorized(request, principal):
            raise ValueError("request failed validation or authorization")
        storage_key = (
            principal["tenant"],
            principal["subject"],
            route,
            request["idempotency_key"],
        )
        scope = {
            "tenant": principal["tenant"],
            "principal": principal["subject"],
            "route": route,
            "idempotency_key": request["idempotency_key"],
        }
        fingerprint = request_fingerprint(request)
        response_generated_at = self.clock()
        stored = self.results.get(storage_key)
        if stored is not None:
            if stored["request_fingerprint"] != fingerprint:
                return {
                    "status": 409,
                    "effect_applied": False,
                    "effect_count": self.effect_count,
                    "scope": scope,
                    "request_fingerprint": fingerprint,
                    "response_generated_at": response_generated_at,
                    "problem_details": {
                        "type": "urn:problem:idempotency-key-conflict",
                        "title": "Idempotency key payload conflict",
                        "status": 409,
                    },
                }
            return {
                "status": 200,
                "effect_applied": False,
                "effect": dict(stored["effect"]),
                "effect_count": self.effect_count,
                "scope": scope,
                "request_fingerprint": fingerprint,
                "response_generated_at": response_generated_at,
            }

        self.transitions.extend(
            [
                {"from": "assigned", "to": "in-progress"},
                {"from": "in-progress", "to": "completed"},
                {"from": "completed", "to": "synced"},
            ]
        )
        self.effect_count += 1
        effect = {
                "operation_id": request["operation_id"],
                "work_item_id": request["work_item_id"],
                "state": "completed",
        }
        self.results[storage_key] = {
            "request_fingerprint": fingerprint,
            "effect": effect,
        }
        return {
            "status": 200,
            "effect_applied": True,
            "effect": dict(effect),
            "effect_count": self.effect_count,
            "scope": scope,
            "request_fingerprint": fingerprint,
            "response_generated_at": response_generated_at,
        }

def classify_change(change):
    # source・wire・semanticを潰さず、旧client traceへ根拠を戻す。
    outcomes = {
        "add_optional_field": {
            "source": {
                "compatible": True,
                "evidence": "旧clientは未知fieldを読み飛ばす",
            },
            "wire": {
                "compatible": True,
                "evidence": "既存required fieldと型を維持する",
            },
            "semantic": {
                "compatible": True,
                "evidence": "省略時の既存意味を維持する",
            },
            "trace_id": "trace-add-optional",
        },
        "remove_required_field": {
            "source": {
                "compatible": False,
                "evidence": "生成済みclientのaccessorが参照できない",
            },
            "wire": {
                "compatible": False,
                "evidence": "旧schemaがrequired field欠落を拒否する",
            },
            "semantic": {
                "compatible": False,
                "evidence": "旧clientが作業完了を判定できない",
            },
            "trace_id": "trace-remove-required",
        },
        "expand_enum": {
            "source": {
                "compatible": False,
                "evidence": "網羅switchの旧clientが未知値を扱えない",
            },
            "wire": {
                "compatible": True,
                "evidence": "JSON stringとしては配送できる",
            },
            "semantic": {
                "compatible": False,
                "evidence": "未知状態を既定値へ誤変換する",
            },
            "trace_id": "trace-expand-enum",
        },
    }
    return outcomes[change]

def main():
    request = {
        "version": "v1",
        "operation_id": "operation-42",
        "idempotency_key": "key-42",
        "tenant": "tenant-a",
        "work_item_id": "work-42",
        "result": "completed",
    }
    principal = {"subject": "field-7", "tenant": "tenant-a"}
    assert structurally_valid(request)
    assert authorized(request, principal)

    clock = FixedClock(
        [
            "2026-08-01T09:00:00Z",
            "2026-08-01T09:00:01Z",
            "2026-08-01T09:00:02Z",
            "2026-08-01T09:00:03Z",
        ]
    )
    service = WorkService(clock)
    initial = service.complete(request, principal, ROUTE)
    replay = service.complete(request, principal, ROUTE)
    same_effect = initial["effect"] == replay["effect"]
    same_response = initial == replay
    assert initial["effect_count"] == replay["effect_count"] == 1
    assert same_effect and not same_response

    conflicting_request = dict(request)
    conflicting_request["operation_id"] = "operation-conflict"
    conflicting_request["result"] = "cancelled"
    conflict = service.complete(conflicting_request, principal, ROUTE)
    assert conflict["status"] == 409
    assert not conflict["effect_applied"]
    assert conflict["effect_count"] == 1
    assert conflict["request_fingerprint"] != initial["request_fingerprint"]

    other_tenant_request = dict(request)
    other_tenant_request["tenant"] = "tenant-b"
    other_tenant_request["operation_id"] = "operation-tenant-b"
    other_tenant_principal = {"subject": "field-7", "tenant": "tenant-b"}
    other_tenant = service.complete(
        other_tenant_request,
        other_tenant_principal,
        ROUTE,
    )
    assert other_tenant["effect_applied"]
    assert other_tenant["effect_count"] == 2
    assert other_tenant["effect"]["operation_id"] != initial["effect"]["operation_id"]

    cross_tenant = dict(request)
    cross_tenant["tenant"] = "tenant-b"
    boundary = {
        "schema_valid": structurally_valid(cross_tenant),
        "authorized": authorized(cross_tenant, principal),
        "reason": "principal-tenant-mismatch",
    }
    assert boundary["schema_valid"] and not boundary["authorized"]

    compatibility = {
        change: classify_change(change)
        for change in (
            "add_optional_field",
            "remove_required_field",
            "expand_enum",
        )
    }
    offline_client_trace = [
        {
            "id": "trace-add-optional",
            "client": "v1-field-device",
            "observed": "unknown field ignored",
        },
        {
            "id": "trace-remove-required",
            "client": "v1-field-device",
            "observed": "required result missing",
        },
        {
            "id": "trace-expand-enum",
            "client": "v1-field-device",
            "observed": "unknown enum rejected",
        },
    ]
    assert {
        change["trace_id"] for change in compatibility.values()
    } == {trace["id"] for trace in offline_client_trace}
    assert initial["scope"] == replay["scope"]
    assert initial["request_fingerprint"] == replay["request_fingerprint"]
    assert initial["response_generated_at"] != replay["response_generated_at"]
    return {
        "harness": HARNESS,
        "fixture": "offline-field-client-v1",
        "supported_versions": SUPPORTED_VERSIONS,
        "idempotency_evidence": {
            "initial": initial,
            "replay": replay,
            "same_effect": same_effect,
            "same_response": same_response,
            "exactly_once_claimed": False,
            "payload_conflict": conflict,
            "other_tenant": other_tenant,
        },
        "compatibility_cases": compatibility,
        "offline_client_trace": offline_client_trace,
        "problem_contract": {
            "rfc_standard_members": [
                "type",
                "title",
                "status",
                "detail",
                "instance",
            ],
            "rfc_members_are_optional": True,
            "required_by_this_api": ["type", "title", "status"],
            "optional_by_this_api": ["detail", "instance"],
        },
        "authorization_boundary": boundary,
        "state_transitions": service.transitions,
        "offline_window_days": 14,
        "external_network_used": False,
    }

print(json.dumps(main(), ensure_ascii=False, indent=2))
PY
side effectとobserved responseを分離するretry trace

注記

図を読む際の補足情報です。

  1. 同じ配送や同じresponse byte列ではなく、保存済みの同じ操作結果を返す契約を示す。

response喪失後に、同じ冪等keyのretryがside effectを二重適用せず結果を返すには何が必要か。

  1. 最初の試行

    request受理とside effect確定を分ける。

    1. request accepted

      schema、認証、認可と冪等key scopeを検証する。

      順序: 0

    2. side effect committed

      keyと操作結果を同じ耐久境界で一度だけ記録する。

      順序: 1

  2. 結果不明

    server側効果とclient側response観測を分ける。

    1. response lost

      効果は確定したがclientはresponseを観測できない。

      順序: 2

  3. 冪等な回復

    同じkeyで保存済み結果を照会する。

    1. retry replayed

      同じkeyを受け、side effectを再適用せず保存済み結果を読む。

      順序: 3

    2. response observed

      clientが操作結果を観測する。response固有metadataは試行ごとに違い得る。

      順序: 4

request受理、side effect確定、response喪失、同じkeyのretry、結果観測を別状態として説明する。

  1. request受理: serverが契約と冪等key scopeを検証する。まだside effect確定とは限らない。; 条件 常時; node request-accepted-node; edge なし
  2. side effect確定: keyと操作結果を同じ耐久境界で記録し、side effectを一度だけ適用する。; 条件 常時; node side-effect-node; edge なし
  3. response喪失: side effectは確定済みだが、clientのobserved responseはない。この二事実を分離する。; 条件 常時; node response-lost-node; edge なし
  4. 同じkeyでretry: 保存済み結果を返し、side effectは再適用しない。; 条件 常時; node retry-replayed-node; edge なし
  5. 結果を観測: clientは保存済み操作結果を観測する。同じresponse byte列を保証したとは主張しない。; 条件 常時; node observed-success-node; edge なし
完全な遷移
イベント開始終了条件
nextrequest-acceptedside-effect-committed常時
timerrequest-acceptedside-effect-committed常時
nextside-effect-committedresponse-lost常時
timerside-effect-committedresponse-lost常時
nextresponse-lostretry-replayed常時
timerresponse-lostretry-replayed常時
nextretry-replayedobserved-success常時
timerretry-replayedobserved-success常時
previousside-effect-committedrequest-accepted常時
previousresponse-lostside-effect-committed常時
previousretry-replayedresponse-lost常時
previousobserved-successretry-replayed常時
resetside-effect-committedrequest-accepted常時
resetresponse-lostrequest-accepted常時
resetretry-replayedrequest-accepted常時
resetobserved-successrequest-accepted常時
観測結果
結果状態
同じkeyのretryでside effectを再適用しない。retry-replayed
保存済み操作結果をclientが観測する。observed-success

現在の状態: request受理 — serverが契約と冪等key scopeを検証する。まだside effect確定とは限らない。

このモデルは例示的かつ決定的であり、実システムの完全な再現ではありません。

トレードオフと失敗モード

API進化と再送方針の decision table
条件 契約上の選択 守れること 残るrisk
応答喪失後に状態変更を再送する client生成keyと結果照会を必須にする 同じ業務効果の重複を防ぐ key期限後、並行中、外部副作用の整合
旧clientが長期間offlineになる v1とv2を併存し利用versionを観測する 強制更新できない期間の継続利用 互換層の費用と意味的負債
任意fieldを追加する 未知fieldを無視するconsumer契約を検証する wire上の後方互換性 fieldが状態解釈を変える意味的非互換
失敗後にclientの操作が必要 Problem Detailsへ安定typeと回復情報を置く statusだけより一貫した分岐 detail文面への誤った機械依存
  • 誤診: 初回と再送でresponseが違うので冪等ではない。反証: 冪等性が比較するのは意図した効果である。時刻やtrace IDを含む表現は変わり得る。副作用回数と対象状態を観測し、同じresponseやexactly-onceを別の保証として扱う。
  • 誤診: OpenAPIとJSON Schemaに通ったrequestなので正しく認可され、業務上も実行可能だ。反証: schemaは主に表現の構造を検証する。主体が別tenantなら認可で拒否し、状態遷移が不変条件を破るなら意味検証で拒否する。
  • 失敗モード: idempotency keyだけ保存し、業務更新と別transactionにすると、crash境界で効果とkeyが不一致になる。
  • 失敗モード: enum追加を常に安全とみなすと、未知値を網羅switchで拒否する旧clientや、既定値へ誤変換するclientを壊す。

知識チェック

  1. 同じ冪等keyの二回目に、初回と異なるstatusまたはresponseを返してよい条件は何か。
  2. schema validだが意味的にinvalidなrequestと、意味的にvalidだがunauthorizedなrequestを一つずつ示せ。
  3. 任意field追加、必須field削除、enum拡張をsource、wire、意味の三面で評価せよ。
  4. Problem Detailsのtype、title、status、detail、instanceをconsumerはどう使い分けるか。

出典と次の学習

HTTP method、status、表現の意味はRFC 9110を基準にする。interface記述はOpenAPI Specification v3.2.0、構造検証はJSON Schema Draft 2020-12のvalidation vocabulary、後方互換の変更分類はAIP-180、共通の失敗表現はRFC 9457へ照合する。Problem Detailsの標準memberはRFC上一律必須ではなく、必要な必須性はAPI profileで明示する。いずれも業務意味と認可の設計を自動化するものではない。

次はcore-08で、契約を実現するmoduleの依存方向を変更理由から決め、代替案と撤回条件をADRへ残す。API versionを増やす前に、変更を局所化できるarchitectureかを検査する。

実践ラボ

オフライン現場端末の再送と契約進化を検証する

提出成果物: 互換性、冪等性、失敗形式を含むAPI契約

  1. v1とv2で共有する作業完了コマンド、状態遷移、認可境界を宣言する
  2. tenant、認証済みprincipal、route、冪等keyをscopeとし、同じpayloadの再送と異なるpayload fingerprintの衝突を検証する
  3. 注入した固定RFC 3339時計で、副作用回数が一のままresponse生成時刻が異なる証拠を記録する
  4. 任意field追加、必須field削除、enum拡張をsource、wire、semanticの三軸で分類しoffline client traceへ結ぶ
  5. RFC 9457の標準memberの任意性と、このAPI profileが必須にするmemberを分けたProblem Detailsを生成する
  6. schema上正しい別tenantのrequestを認可で拒否し、外部networkなしで三つ以上の状態遷移を検査する

説明して理解を確かめる

5分で、HTTPの冪等methodと業務上の冪等key、同じ効果と同じresponse、at-least-once再送とexactly-once主張を分け、schema妥当性が業務意味と認可を保証しない理由を説明する。

アセスメント

  1. 問い: 端末が作業完了POSTの応答を失い、同じkeyで再送した。初回と異なるresponse時刻を返したら冪等でないか。

    期待する証拠: 対象resourceの意図した効果、副作用回数、response表現の違い、保存済み結果、結果照会、exactly-once非保証の区別

  2. 問い: v2でenum値を追加した。JSON Schemaを通る旧clientなら互換と断定できるか。

    期待する証拠: 旧clientの未知値処理、sourceとwireと意味の互換性、長期offline端末、段階的提供と観測

別問題へ転用する

長時間オフラインになる現場端末へ互換な再送・同期API契約を設計する

復習スケジュール

  1. 1日後

    同じ効果だが同じresponseでない冪等再送の例を示す

  2. 7日後

    schema妥当性だけでは検出できない業務違反と認可違反は何か

  3. 30日後

    enum追加が意味的に破壊的となる旧clientの挙動を示す

  4. 90日後

    同じ効果だが同じresponseでない冪等再送の例を示す

評価ルーブリック

4段階の評価基準
観点未達発展途上熟達卓越
technical-correctnessschemaが通れば業務と認可も正しいとみなし、timeout時に無条件再送する冪等keyとversionはあるが、状態遷移または失敗形式の意味が曖昧であるHTTP、schema、意味、認可、冪等性、失敗、互換性を分けて契約化する並行再送、key保持期間、意味変更、未知値の反例と限界まで扱う
judgmentproviderの都合だけで破壊的変更を即時公開するversionを増やすが、利用者の更新遅延と運用費用を比較しない利用者分布、撤回可能性、観測、移行期間から進化方針を選ぶ互換層の費用と意味的負債を定量化し、廃止条件とrollbackを合意する
evidenceOpenAPI文書が生成されたことだけを互換性の証拠にするschema差分はあるが、replay時の副作用と旧client挙動を検証しない固定fixtureで再送、状態遷移、失敗、認可、互換性を機械検査する利用中versionの観測とconsumer例を結び、意味変更の反証まで自動化する
communicationstatus codeとfield一覧だけで、利用者が次の操作を判断できない成功経路は分かるが、retry可否と移行期限が不明である前提、状態、失敗、再送、互換性、廃止条件をconsumer視点で説明する実装者、運用者、利用者へ同じ契約証拠から異なる判断情報を提供する

出典

以下の外部資料は利用者が選択したときだけ開きます。