0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HTTP 202のreceipt照合で重複再送を止める【開発日誌 Day30】

0
Posted at

再送するクライアントと、先にreceiptを照合するクライアントを比べる。両方とも圏外復帰には耐えるが、応答だけを失った時の結果が違う。

前回のDay29では、attempt_startedresponse_receivedsent_committed の3イベントでOutboxの進行を追った。ただし、応答が端末へ届く直前に通信が切れた場合、クライアントのログだけでは配送先が処理を受理したか分からない。

結論から書く。POSTを無条件に再送するのではなく、最初の要求に冪等性キーを付け、配送先が安定したreceipt IDと照合URLを返す契約にする。再起動後は同じキーで状態照会し、acceptedcompletedunknown を区別してから再送を決める。HTTP 202は「処理が完了した」という印ではない。受理と完了を分けるための入口である。

再送と照合は何が違うのか?

比較したのは3案だ。

方式 応答消失後の次手 配送先に必要な能力 重複を避ける根拠
A. 同じPOSTを即再送 同じ本文をもう一度送る 冪等性キーの重複排除 配送先がキーを覚えていること
B. receiptを先に照合 GET /receipts/{id} receiptの永続化と照会API 既存結果を読んでから決めること
C. 利用者に選ばせる 再送か破棄を画面で選ぶ 特になし 自動処理を止めること

Aは小さい。通信が戻ったら同じ冪等性キーで再送し、配送先が同じ操作へ畳む。ところが、配送先がキーを保持する期間をクライアントが知らなければ、古いOutboxを再送した時に新規操作として扱われる可能性が残る。

BはAPIが増える。しかし、受理済みか、処理完了か、記録が見つからないかを分けられる。今回はこちらを選んだ。キャプチャを止めないためにCを通常経路へ置かず、Bで判断不能になった時だけ保留状態を見せる。

HTTPの冪等性だけではPOSTを守れない

RFC 9110で、同じ要求を複数回送っても意図したサーバー側の効果が1回と同じになる性質を冪等と呼ぶ。GET、HEAD、PUT、DELETEなどにはメソッドとして冪等な意味が定義されている。一方、POSTはそれだけで冪等ではない。

つまり、URLSession が同じPOSTを再実行したからといって、HTTPが重複排除してくれるわけではない。アプリケーション側で「同じ操作」を識別する契約が要る。

struct DeliveryRequest: Encodable, Sendable {
    let operationID: UUID
    let text: String
}

func makeRequest(for record: OutboxRecord) throws -> URLRequest {
    var request = URLRequest(url: endpoint)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.setValue(
        record.idempotencyKey.uuidString,
        forHTTPHeaderField: "Idempotency-Key"
    )
    request.httpBody = try JSONEncoder().encode(
        DeliveryRequest(
            operationID: record.idempotencyKey,
            text: record.originalText
        )
    )
    return request
}

ここで Idempotency-Key はアプリと配送先の契約として使う。一般のHTTPメソッドが持つ冪等性と、独自POSTへ付加する重複排除の約束を混同しない。ヘッダー名だけ実装しても、配送先が保存・照合・期限切れを定義しなければ効果はない。

200、201、202を一つの成功へ潰さない

200...299 を成功としてまとめるコードは、単純なアップロードでは読みやすい。AppleのURL Loading Systemの例も、まずHTTP応答を HTTPURLResponse として受け、2xx範囲か確認する形を示している。

しかし、Outboxの状態機械では2xxを一つに潰すと情報を失う。

status クライアントが読める事実 Outboxを即 sent にできるか
200 OK 要求に対する表現が返った 応答本文の契約次第
201 Created 新しい資源が作成された receiptと資源IDを検証できるなら可能
202 Accepted 処理を受理したが完了は未保証 できない。照合へ進む
204 No Content 応答本文はない サーバー契約が完了を意味する時だけ可能

RFC 9110の202は、要求が処理のために受理されたが、処理はまだ完了していない可能性がある状態だ。後で失敗することもある。したがって、202を受け取った瞬間にOutboxを sent にすると、端末側だけが完了したと思い込む。

状態を増やす。

enum DeliveryState: Codable, Sendable {
    case pending
    case sending(attempt: UInt32)
    case accepted(receipt: ReceiptPointer)
    case sent(receiptID: String)
    case needsReview(reason: ReviewReason)
}

struct ReceiptPointer: Codable, Sendable, Equatable {
    let id: String
    let statusURL: URL
}

accepted は失敗ではない。成功でもない。「配送先に照合できる手掛かりがある」という独立状態だ。

receipt IDに何を要求するか

receipt IDは、見た目がUUIDなら十分というものではない。今回の契約では4条件を置いた。

  1. 同じ冪等性キーには同じreceipt IDを返す。
  2. 別の操作には、本文が同じでも別のreceipt IDを返す。
  3. receipt ID単体では本文や送信先を推測できない。
  4. receipt照会には通常の認証を要求し、IDを知るだけでは読めない。

一つ目が再送との接点だ。最初のPOSTが処理され、応答だけ端末へ届かなかったとする。再起動後に同じキーを送った時、配送先が新しいreceiptを発行したら、重複を検出できない。同じreceiptを返すから、クライアントは「前の操作と同じだ」と判断できる。

二つ目は本文ハッシュをIDにしない理由でもある。同じ「牛乳を買う」を朝と夜に送ったなら、操作は2件だ。本文の同一性と操作の同一性は別である。

三つ目と四つ目は、receiptを公開URLの秘密鍵にしないための境界だ。ランダムなIDでも、照合URLへ認証なしでアクセスできれば実質的なbearer tokenになる。診断用trace IDとも分ける。

Locationヘッダーを照合URLとして読む

201 Createdでは、新しく作られた資源の識別に Location を返せる。202 Acceptedでも、処理状況を説明する表現と監視先をアプリケーション契約として返せる。今回はレスポンスJSONにreceipt ID、Location に照合URLを置く。

HTTP/1.1 202 Accepted
Content-Type: application/json
Location: https://api.example.com/receipts/r_7K2Q

{"receipt_id":"r_7K2Q","status":"accepted"}

Swiftでは allHeaderFields の辞書を自前で大小文字比較せず、value(forHTTPHeaderField:) を使う。Appleのドキュメントも、HTTPヘッダー名は大小文字を区別しない一方、Swiftの辞書キーは区別するため、このメソッドによる参照を案内している。

struct AcceptedBody: Decodable {
    let receiptID: String
    let status: String

    enum CodingKeys: String, CodingKey {
        case receiptID = "receipt_id"
        case status
    }
}

func parseAccepted(
    data: Data,
    response: HTTPURLResponse
) throws -> ReceiptPointer {
    guard response.statusCode == 202 else {
        throw ReceiptError.unexpectedStatus(response.statusCode)
    }

    let body = try JSONDecoder().decode(AcceptedBody.self, from: data)
    guard body.status == "accepted" else {
        throw ReceiptError.invalidBody
    }
    guard
        let rawLocation = response.value(forHTTPHeaderField: "Location"),
        let statusURL = URL(string: rawLocation),
        statusURL.scheme == "https"
    else {
        throw ReceiptError.invalidLocation
    }

    return ReceiptPointer(id: body.receiptID, statusURL: statusURL)
}

ここでは Location とJSONの両方を検証する。どちらか片方だけを信じると、配送先の不具合で別receiptのURLとIDを組み合わせる余地が残る。本番ではstatus URLのhostも許可リストと照合する。

再起動後に再送より先に何をするか?

accepted のままアプリが終了したなら、次回起動時の最初の通信はPOSTではなくGETにする。

enum ReceiptStatus: String, Decodable {
    case accepted
    case processing
    case completed
    case failed
}

struct ReceiptResponse: Decodable {
    let receiptID: String
    let status: ReceiptStatus

    enum CodingKeys: String, CodingKey {
        case receiptID = "receipt_id"
        case status
    }
}

func fetchReceipt(_ pointer: ReceiptPointer) async throws -> ReceiptResponse {
    var request = URLRequest(url: pointer.statusURL)
    request.httpMethod = "GET"
    request.setValue("application/json", forHTTPHeaderField: "Accept")

    let (data, rawResponse) = try await session.data(for: request)
    guard let response = rawResponse as? HTTPURLResponse else {
        throw ReceiptError.nonHTTPResponse
    }
    guard response.statusCode == 200 else {
        throw ReceiptError.unexpectedStatus(response.statusCode)
    }

    let receipt = try JSONDecoder().decode(ReceiptResponse.self, from: data)
    guard receipt.receiptID == pointer.id else {
        throw ReceiptError.mismatchedReceipt
    }
    return receipt
}

completed ならOutboxを sent へ進める。accepted または processing なら待つ。failed なら、同じキーで再送できる失敗か、入力修正が要る恒久失敗かをエラーコードで分ける。

難しいのは404だ。receiptが存在しない、とだけ読んではいけない。保持期限切れ、認証範囲の違い、別リージョンへの接続、配送先の一時的不整合もあり得る。404を見た瞬間に新しいキーでPOSTすると、古い操作が生きていた場合に重複する。

自分は404を unknown として needsReview へ置く。通常のキャプチャは続けられるが、その1件だけ自動再送しない。

同じキーで再送する案との比較

receipt照合にも欠点はある。API、状態、テストが増える。そこで、同じキーで即再送する案と、障害位置ごとに比べた。

障害位置 即再送 receipt照合
要求送信前に終了 同じキーで再送 receiptがないため同じキーで再送
配送先が受理、応答前に終了 配送先の重複排除に依存 キー照会または既存receipt取得
202受信後に終了 同じPOSTを再送 receipt GETで進行を確認
完了後、端末commit前に終了 同じPOSTを再送 completedを読んで端末commit
キー保持期限後に復帰 重複の危険 receipt保持期限次第でunknown

小さなシステムなら即再送で十分な場合もある。配送先が同じキーをOutboxの最大保持期間より長く保存し、同じ結果を確実に返すなら、照合APIは重複した機能になる。

今回receipt照合を選んだ理由は、非同期処理があるからだ。受理、配送、端末側commitの3時点が離れている。202を返す設計で「同じPOSTをもう一度送ればよい」だけにすると、処理中と完了済みを区別する情報を捨てる。

pollingを無限ループにしない

照合APIを作ると、次に起きる事故は無限pollingだ。while status != completed と書けば短いが、バックグラウンド時間、電池、配送先の負荷を無視する。

polling回数や秒数を根拠なく記事へ置くのも避けたい。そこでクライアントは、配送先の Retry-After が妥当ならそれを尊重し、なければアプリの次回通常起動へ照合を回す。即時完了を利用者へ約束しない。

func nextCheckDate(from response: HTTPURLResponse, now: Date) -> Date? {
    guard let raw = response.value(forHTTPHeaderField: "Retry-After") else {
        return nil
    }
    guard let seconds = TimeInterval(raw), seconds >= 0 else {
        return nil
    }
    return now.addingTimeInterval(seconds)
}

この例はdelta-secondsだけを扱う。HTTP-date形式まで扱うなら専用の厳密なパーサーを追加する。読めない値を0秒と解釈して連打せず、次回起動へ回す方を安全側とした。

receipt契約をどうテストするか

クライアント単体テストでは、少なくとも6つの応答列を固定する。

ケース 1回目 再起動後 期待する最終状態
同期完了 201 + receipt なし sent
非同期完了 202 + receipt GET 200 completed sent
処理継続 202 + receipt GET 200 processing accepted
恒久失敗 202 + receipt GET 200 failed needsReview
receipt不一致 202 + A GET 200 B needsReview
receipt不明 202 + receipt GET 404 needsReview

重要なのは、時間を待つテストにしないことだ。URLProtocol または注入したtransportで応答列を制御し、Outboxのcommit地点ごとに終了を差し込む。

@Test
func acceptedReceiptCompletesAfterRelaunch() async throws {
    let transport = ScriptedTransport([
        .post(status: 202, receipt: .accepted("r_7K2Q")),
        .get(status: 200, receipt: .completed("r_7K2Q"))
    ])
    let store = InMemoryOutboxStore()

    let firstWorker = DeliveryWorker(store: store, transport: transport)
    try await firstWorker.deliverNext()
    #expect(try await store.state() == .accepted("r_7K2Q"))

    let relaunchedWorker = DeliveryWorker(store: store, transport: transport)
    try await relaunchedWorker.reconcileAccepted()
    #expect(try await store.state() == .sent("r_7K2Q"))
    #expect(await transport.postCount == 1)
}

最後の postCount == 1 が今回の核心だ。再起動したのにPOSTは増えず、GETで既存結果を確かめている。

反証: receiptは新しい単一障害点になる

receiptを採用すれば重複が消える、とは言えない。receipt保存が配送処理と別トランザクションなら、本文だけ受理してreceiptを書けない窓が生まれる。反対にreceiptだけ作って実処理をenqueueできなければ、永遠に accepted のまま残る。

配送先では、冪等性キーの登録、receiptの作成、処理キューへの投入をどこまで同じ原子的境界へ入れられるかが問題になる。完全に一つへできないなら、各段階を再実行可能にし、receipt自体にも進行状態を残す必要がある。

クライアント側も同じだ。202応答を受けてから accepted を保存する前に終了すれば、receipt URLを失う。次回は同じキーでPOSTし、配送先から同じreceiptを再取得できなければならない。receipt照合は冪等性キーの代わりではなく、その結果を観測する面である。

FAQ

Q. HTTP 202を受け取れば送信成功か?
A. 完了とは限らない。要求が処理のために受理されたことを示す。照合URLやreceipt状態を読み、完了を別に確認する。

Q. POSTへ同じIdempotency-Keyを付ければ十分か?
A. 配送先がキーの保存期間、同一要求の判定、再送時の応答を定義しているなら小さな構成では十分になり得る。ヘッダーを付けるだけでは足りない。

Q. receipt IDをログへ出してよいか?
A. 認証権限を持たず、本文や利用者から独立したランダム値でも、共有範囲と保持期間を評価する。配送キーや照合用bearer tokenとの流用は避ける。

Q. 404なら同じ本文を新しいキーで再送してよいか?
A. 自動では行わない。保持期限切れや一時的不整合の可能性がある。契約で安全を証明できない限り unknown として保留する。

Q. receipt照合は必ずGETで作るべきか?
A. GETは現在状態の取得に自然だが、認証、キャッシュ、保持期間を含むAPI契約が先である。IDがURLへ露出する影響も評価する。

自分はreceipt照合を選んだ

Day29では、端末内の3イベントだけで「どの境界まで進んだか」を追った。Day30では、その先にある配送先の事実をreceiptで照合する面を加えた。

選んだのは、同じPOSTを即再送する案ではなく、accepted を永続化してGETで照合する案だ。理由は、202を使う非同期処理では受理と完了が別だからである。冪等性キーは重複を畳み、receiptは既存結果を読む。役割を一つのUUIDへ押し込まない。

次はreceiptの保持期限を扱う。端末のOutboxが30日残り、配送先のキーが7日で消える設計なら、8日目から自動再送の根拠がない。日数を先に決めず、期限をクライアントへどう伝え、期限切れの1件だけをどう止めるかを詰める。

判断が割れそうなのは、receiptが見つからない1件を利用者へ見せるか、暗号化した保留箱へ静かに残すかである。自分は通知バッジを増やさず、履歴画面だけに「確認が必要」と出す方を選ぶ。キャプチャの流れを止めないためだ。

参考リンク


Obsidian連携シンプルメモ
再送を急がずOutboxとreceiptを照合し、書いた内容を失わず重複も増やさないiPhoneメモアプリ。
App Store:https://apps.apple.com/jp/app/id6758438948

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?