🗂

【iOS】【課金】StoreKitのリスナーに永遠に終わらないトランザクションが入ってくる問題が発生した

2025/03/25に公開

以前iOSDCで登壇した際に、StoreKit 2のリスナータスクの話はしたので、設定などはそちらに譲ります。

https://speakerdeck.com/0si43/storekit-2niyorumodannaapurinei-ke-jin?slide=33

このように設定してました。

func listen() {
    updateListenerTask = Task {
        for await result in Transaction.updates {
            switch result {
            case .unverified:
                throw PurchaseError.transactionFailed
            case let .verified(transaction):
                // 社内サーバーへの購入リクエスト
            }
        }
    }
}

ただQAのときに、「永遠に終わらないトランザクション」が稀に発生することを確認していました。
これが発生すると、アプリ起動のたびに.verifiedなトランザクションが入ってきて、永遠に終了しません。
結局原因は特定できず、再発もしないままでした。
所謂エッジケースでした。

このたび原因が判明したので、誰かの役に立つかもしれないので書いておきます。

原因

購入トランザクションの下記タイミング、

  1. App Storeで購入成功
  2. 自社サーバーでも購入完了
  3. 自社サーバーからアプリへのレスポンス未達

で終えたときに、終了しないトランザクションが発生します。
iOSアプリは終了してないと認識していますが、実際は購入は完了しています。
なので、このリクエストを再送しても、サーバーサイドで重複リクエストなのでエラー扱いです。
エラーになったトランザクションはtransaction.finish()していなかったので、次のアプリ起動時に再送がかかります。

詳細

購入トランザクションは、以下の順でリクエストとレスポンスをやりとりします。

iOSアプリ→App Store
→iOSアプリ→社内サーバー
→iOSアプリ

今回のケースでは、最後の社内サーバー→iOSアプリのレスポンスが受け取れなかった場合ですが、
理論上エラーは通信している場所すべてで発生しえます。
ただし、再送で解決しないケースは最後だけです。

iOSアプリ→App Store

このケースは最初のリクエストに失敗なので、購入自体が発生していません。
よって問題なし。

App Store→iOSアプリ

App Storeからのレスポンス取得失敗。
この場合、Appleアカウントへの決済は完了しますので、ユーザーの支払いが発生したのに、社内DBへの反映がされません。

ただし、このケースは再送で解決します。
そのためのリスナータスクなので、次にアプリを起動したタイミングで解決するはずです。

iOSアプリ→社内サーバー

アプリから社内サーバーへの購入リクエストの送信失敗。
この場合でも、状況は一つ前のケースとほぼ一緒です。
再送で解決することを想定しています。

社内サーバー→iOSアプリ

App Storeも社内DBも購入完了しているが、iOSアプリがレスポンスをもらえず、
transaction.finish()を呼べなかったケース。
このケースは、購入自体問題ないのですが、上述の通り、再送がループする問題が発生します。

再現手順

アプリ内課金を行なった途中でアプリを落とすと、タイミングによっては再現できます。
かなりシビアですが。
適宜Xcodeつないでコード内のリスナータスクやリスナータスク内の再送レスポンスの取得をいじってあげると、
永遠に終わらない再送トランザクションの再現が可能です。

対策

再送リクエストの購入エラーをすべてfinish()させるとさすがに問題があるので、
サーバーサイドで重複購入のエラーを指定してもらって、その場合は問題ないエラーと判断して、finish()するようにしました。

case let .verified(transaction):
    do {
        // 社内サーバーへの購入リクエスト
        await transaction.finish()
    } catch {
        if let _ = error as? DuplicatePurchaseError {
                await transaction.finish()
        }
    }
}

(了)

Discussion