BillingResult のレスポンス コードを処理する

Play Billing Library の呼び出しでアクションがトリガーされると、ライブラリは BillingResult レスポンスを返し、結果をデベロッパーに通知します。たとえば、queryProductDetailsAsync を使用してユーザーが利用できる特典を取得する場合、レスポンス コードにより、適切な ProductDetails オブジェクトと「OK」コード、または ProductDetails オブジェクトを取得できなかった理由が通知されます。

すべてのレスポンス コードがエラーとは限りません。BillingResponseCode のリファレンス ページでは、このガイドで説明している各レスポンスについて詳しく説明しています。エラーではないレスポンス コードの例には以下があります。

レスポンス コードがエラーを示していても、原因が一時的なものであれば復元は可能です。Play Billing Library メソッドを呼び出し、復元可能な状態を示す BillingResponseCode 値が返された場合は、同じメソッドをもう一度呼び出してみるとよいでしょう。それ以外の場合は一時的なものではないため、再試行はおすすめできません。

一時的なエラーでは、ユーザーのセッション中にエラーが発生したか(ユーザーが購入フローを進めているときなど)、バックグラウンドでエラーが発生したか(onResume 時にユーザーの既存の購入をクエリするときなど)といった、要因に応じて異なる再試行手段が必要になります。以下の再試行手段のセクションでは、さまざまな再試行手段の例を説明し、再試行可能な BillingResult レスポンスのセクションではレスポンス コードごとに最も効果的な手段を紹介します。

レスポンス コードに加えて、一部のエラー レスポンスには、デバッグとロギングを目的としたメッセージもあります。

再試行手段

シンプルな再試行

ユーザーがセッション中の場合は、エラーによってユーザー エクスペリエンスをできる限り妨げることがないよう、シンプルな再試行手段を実行する方が賢明です。このような場合は、終了条件として最大試行回数を設定したシンプルな再試行手段をおすすめします。

次の例はシンプルな再試行手段で、BillingClient 接続を確立するときのエラーを処理する方法です。

class BillingClientWrapper(context: Context) : PurchasesUpdatedListener {
  // Initialize the BillingClient.
  private val billingClient = BillingClient.newBuilder(context)
    .setListener(this)
    .enablePendingPurchases()
    .build()

  // Establish a connection to Google Play.
  fun startBillingConnection() {
    billingClient.startConnection(object : BillingClientStateListener {
      override fun onBillingSetupFinished(billingResult: BillingResult) {
        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
          Log.d(TAG, "Billing response OK")
          // The BillingClient is ready. You can now query Products Purchases.
        } else {
          Log.e(TAG, billingResult.debugMessage)
          retryBillingServiceConnection()
        }
      }

      override fun onBillingServiceDisconnected() {
        Log.e(TAG, "GBPL Service disconnected")
        retryBillingServiceConnection()
      }
    })
  }

  // Billing connection retry logic. This is a simple max retry pattern
  private fun retryBillingServiceConnection() {
    val maxTries = 3
    var tries = 1
    var isConnectionEstablished = false
    do {
      try {
        billingClient.startConnection(object : BillingClientStateListener {
          override fun onBillingSetupFinished(billingResult: BillingResult) {
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
              isConnectionEstablished = true
              Log.d(TAG, "Billing connection retry succeeded.")
            } else {
              Log.e(
                TAG,
                "Billing connection retry failed: ${billingResult.debugMessage}"
              )
            }
          }
        })
      } catch (e: Exception) {
        e.message?.let { Log.e(TAG, it) }
        tries++
      }
    } while (tries <= maxTries && !isConnectionEstablished)
  }
  ...
}

指数バックオフの再試行

Play Billing Library 処理がバックグラウンドで発生し、ユーザー セッション中のユーザー エクスペリエンスに影響しない場合は、指数バックオフの使用をおすすめします。

たとえば、新規購入の確認時などがこれに該当します。この処理はバックグラウンドで発生し、エラーが発生してもリアルタイムで確認を行う必要はないためです。

private fun acknowledge(purchaseToken: String): BillingResult {
  val params = AcknowledgePurchaseParams.newBuilder()
    .setPurchaseToken(purchaseToken)
    .build()
  var ackResult = BillingResult()
  billingClient.acknowledgePurchase(params) { billingResult ->
    ackResult = billingResult
  }
  return ackResult
}

suspend fun acknowledgePurchase(purchaseToken: String) {

  val retryDelayMs = 2000L
  val retryFactor = 2
  val maxTries = 3

  withContext(Dispatchers.IO) {
    acknowledge(purchaseToken)
  }

  AcknowledgePurchaseResponseListener { acknowledgePurchaseResult ->
    val playBillingResponseCode =
    PlayBillingResponseCode(acknowledgePurchaseResult.responseCode)
    when (playBillingResponseCode) {
      BillingClient.BillingResponseCode.OK -> {
        Log.i(TAG, "Acknowledgement was successful")
      }
      BillingClient.BillingResponseCode.ITEM_NOT_OWNED -> {
        // This is possibly related to a stale Play cache.
        // Querying purchases again.
        Log.d(TAG, "Acknowledgement failed with ITEM_NOT_OWNED")
        billingClient.queryPurchasesAsync(
          QueryPurchasesParams.newBuilder()
            .setProductType(BillingClient.ProductType.SUBS)
            .build()
        )
        { billingResult, purchaseList ->
          when (billingResult.responseCode) {
            BillingClient.BillingResponseCode.OK -> {
              purchaseList.forEach { purchase ->
                acknowledge(purchase.purchaseToken)
              }
            }
          }
        }
      }
      in setOf(
         BillingClient.BillingResponseCode.ERROR,
         BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
         BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
       ) -> {
        Log.d(
          TAG,
          "Acknowledgement failed, but can be retried --
          Response Code: ${acknowledgePurchaseResult.responseCode} --
          Debug Message: ${acknowledgePurchaseResult.debugMessage}"
        )
        runBlocking {
          exponentialRetry(
            maxTries = maxTries,
            initialDelay = retryDelayMs,
            retryFactor = retryFactor
          ) { acknowledge(purchaseToken) }
        }
      }
      in setOf(
         BillingClient.BillingResponseCode.BILLING_UNAVAILABLE,
         BillingClient.BillingResponseCode.DEVELOPER_ERROR,
         BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED,
       ) -> {
        Log.e(
          TAG,
          "Acknowledgement failed and cannot be retried --
          Response Code: ${acknowledgePurchaseResult.responseCode} --
          Debug Message: ${acknowledgePurchaseResult.debugMessage}"
        )
        throw Exception("Failed to acknowledge the purchase!")
      }
    }
  }
}

private suspend fun <T> exponentialRetry(
  maxTries: Int = Int.MAX_VALUE,
  initialDelay: Long = Long.MAX_VALUE,
  retryFactor: Int = Int.MAX_VALUE,
  block: suspend () -> T
): T? {
  var currentDelay = initialDelay
  var retryAttempt = 1
  do {
    runCatching {
      delay(currentDelay)
      block()
    }
      .onSuccess {
        Log.d(TAG, "Retry succeeded")
        return@onSuccess;
      }
      .onFailure { throwable ->
        Log.e(
          TAG,
          "Retry Failed -- Cause: ${throwable.cause} -- Message: ${throwable.message}"
        )
      }
    currentDelay *= retryFactor
    retryAttempt++
  } while (retryAttempt < maxTries)

  return block() // last attempt
}

再試行可能な BillingResult レスポンス

NETWORK_ERROR(エラーコード 12)

問題

このエラーはデバイスと Play システムの間のネットワーク接続に問題が発生したことを示します。

考えられる解決策

復元するには、エラーをトリガーしたアクションに応じて、シンプルな再試行または指数バックオフを行います。

SERVICE_TIMEOUT(エラーコード -3)

問題

このエラーは、Google Play が応答する前にリクエストがタイムアウトになったことを示します。Play Billing Library の呼び出しによってリクエストされたアクションの実行遅延などが原因で発生することがあります。

考えられる解決策

通常、これは一時的な問題です。エラーが返されたアクションに応じて、シンプルな再試行手段または指数バックオフの再試行手段のいずれかを使用し、リクエストを再試行します。

以下の SERVICE_DISCONNECTED とは異なり、Google Play 請求サービスへの接続は切断されないため、試行した Play Billing Library 処理のみを再試行します。

SERVICE_DISCONNECTED(エラーコード -1)

問題

この致命的なエラーは、BillingClient を介したクライアント アプリの Google Play ストア サービスへの接続が切断されたことを示します。

考えられる解決策

このエラーをできる限り回避するには、Play Billing Library で呼び出しを行う前に、常に BillingClient.isReady() を呼び出して Google Play 開発者サービスへの接続を確認します。

SERVICE_DISCONNECTED から復元するには、クライアント アプリは BillingClient.startConnection を使用して接続を再確立する必要があります。

SERVICE_TIMEOUT と同様に、エラーをトリガーしたアクションに応じて、シンプルな再試行または指数バックオフの再試行を行います。

SERVICE_UNAVAILABLE(エラーコード 2)

注意事項:

Google Play Billing Library 6.0.0 以降、ネットワークの問題に対しては SERVICE_UNAVAILABLE が返されなくなりました。課金サービスが使用できない場合と、非推奨の SERVICE_TIMEOUT ケースシナリオの場合には返されます。

問題

この一時的なエラーは、Google Play 請求サービスが現在利用できないことを示します。ほとんどの場合、クライアント デバイスと Google Play 請求サービスとの間のネットワーク接続に問題があることが原因です。

考えられる解決策

通常、これは一時的な問題です。エラーが返されたアクションに応じて、シンプルな再試行手段または指数バックオフの再試行手段のいずれかを使用し、リクエストを再試行します。

SERVICE_DISCONNECTED と異なり、Google Play 請求サービスへの接続は切断されていないため、試行した処理をもう一度行います。

BILLING_UNAVAILABLE(エラーコード 3)

問題

このエラーは、購入プロセス中にユーザー請求エラーが発生したことを示します。次のようなケースが考えられます。

  • ユーザーのデバイスの Google Play ストア アプリが最新版でない。
  • ユーザーがサポート対象外の国に居住している。
  • ユーザーが企業ユーザーであり、企業の管理者によってユーザーによる購入が無効にされている。
  • Google Play がユーザーのお支払い方法に対して請求できない(ユーザーのクレジット カードの有効期限が切れている場合など)。

考えられる解決策

このようなケースでは、自動再試行を使用できない場合があります。ただし、問題の原因となった事象に対処することで、手動での再試行は可能です。たとえば、ユーザーが Google Play ストアをサポートされているバージョンに更新すれば、最初の処理を手動で再試行できます。

ユーザーがセッション中でないときにこのエラーが発生した場合は、再試行しても意味がない場合があります。購入フローの結果として BILLING_UNAVAILABLE エラーが発生した場合、そのユーザーは購入プロセス中に Google Play からフィードバックを受け取り、エラーの原因を認識している可能性が非常に高いです。このような場合は、エラーの原因を示すエラー メッセージを表示し、[再試行] ボタンでユーザーが問題に対応した後に手動で再試行できるようにします。

ERROR(エラーコード 6)

問題

これは Google Play 内部の問題を示す致命的なエラーです。

考えられる解決策

ERROR を引き起こす Google Play の内部の問題は一時的な場合があり、指数バックオフの再試行を実装することで軽減できます。ユーザーがセッション中の場合は、シンプルな再試行をおすすめします。

ITEM_ALREADY_OWNED

問題

このレスポンスは、Google Play ユーザーが購入しようとしている定期購入または 1 回だけの購入アイテムをすでに所有していることを示します。ほとんどの場合、これは一時的なエラーではありません。ただし、Google Play の古いキャッシュが原因の場合もあります。

考えられる解決策

原因がキャッシュの問題でない場合にこのエラーの発生を防ぐには、ユーザーがすでに所有しているアイテムを販売しないようにします。購入できるアイテムを表示する際にユーザーの利用資格を確認し、それに応じてユーザーが購入できるものをフィルタします。クライアント アプリがキャッシュの問題によりこのエラーを受け取った場合は、エラーがトリガーとなって、Google Play のキャッシュが Google Play のバックエンドの最新データに更新されます。このケースでは、このエラー発生後に再試行することにより、この一時的なインスタンスは解決するはずです。ITEM_ALREADY_OWNED が返された後 BillingClient.queryPurchasesAsync() を呼び出し、ユーザーがアイテムを購入できたかどうかを確認します。購入できていない場合は、シンプルな再試行ロジックを実行し、購入を再試行します。

ITEM_NOT_OWNED

問題

この購入レスポンスは、Google Play ユーザーが置換、確認、消費しようとしている定期購入または 1 回だけの購入アイテムを所有していないことを示します。ほとんどの場合、これは一時的なエラーではありません。ただし、Google Play の古いキャッシュが原因の場合もあります。

考えられる解決策

キャッシュの問題が原因でエラーが発生すると、このエラーによって Google Play のキャッシュがトリガーされ、Play のバックエンドの最新データが更新されます。エラーがこの固有の一時的なインスタンスを解決した後で、シンプルな再試行手段を使って再試行します。ITEM_NOT_OWNED が返された後、BillingClient.queryPurchasesAsync() を呼び出し、ユーザーがアイテムを購入したかどうかを確認します。購入していない場合は、シンプルな再試行ロジックを使用して、購入を再試行します。

再試行不可の BillingResult レスポンス

再試行ロジックを使用してこれらのエラーを復旧することはできません。

FEATURE_NOT_SUPPORTED

問題

再試行不可エラーは、ユーザーのデバイスでは Google Play 請求サービス機能がサポートされていないことを示します。Google Play ストアのバージョンが古いことが原因の可能性があります。

たとえば、ユーザーのデバイスがアプリ内メッセージングに対応していない場合が挙げられます。

考えられる対応策

Play Billing Library を呼び出す前に、BillingClient.isFeatureSupported() を使用して機能のサポート状況を確認します。

when {
  billingClient.isReady -> {
    if (billingClient.isFeatureSupported(BillingClient.FeatureType.IN_APP_MESSAGING)) {
       // use feature
    }
  }
}

USER_CANCELED

問題

ユーザーが請求フローの UI を終了しました。

考えられる解決策

これは単なる情報であり、適切に失敗する可能性があります。

ITEM_UNAVAILABLE

問題

このユーザーは Google Play 請求サービスの定期購入または 1 回限りの購入アイテムを購入できません。

考えられる対応策

推奨されているとおり、queryProductDetailsAsync で商品の詳細を更新するようにします。Google Play Console の構成での商品カタログが変更される頻度を踏まえ、必要に応じて追加で更新してください。queryProductDetailsAsync で正しい情報を返すアイテムのみを Google Play 請求サービスで販売するようにしてください。アイテムの利用資格の構成に不整合がないことを確認します。たとえば、ユーザーが購入しようとしている地域では購入できないアイテムをクエリしている可能性があります。アイテムを購入可能にするには、アイテムを有効にしてアプリを公開し、ユーザーの居住国でアプリを入手できるようにする必要があります。

特にテスト中などは、アイテムの構成に問題がないにもかかわらず、ユーザーにこのエラーが表示されることがあります。これは Google のサーバー間で商品の詳細の伝達が遅延していることによる場合があります。しばらくしてからもう一度お試しください。

DEVELOPER_ERROR

問題

これは致命的なエラーで、API の不適切な使用を示します。たとえば、BillingClient.launchBillingFlow に誤ったパラメータを指定すると、このエラーが発生します。

考えられる解決策

さまざまな Play Billing Library の呼び出しを正しく使用していることを確認してください。また、エラーに関する詳細情報をデバッグ メッセージで確認します。