Cómo administrar códigos de respuesta BillingResult

Cuando una llamada de la biblioteca de Play Billing activa una acción, la biblioteca muestra una respuesta BillingResult para informar el resultado a los desarrolladores. Por ejemplo, si usas queryProductDetailsAsync con el objetivo de obtener las ofertas disponibles para el usuario, el código de respuesta contiene un código correcto y proporciona el objeto ProductDetails correcto, o bien contiene una respuesta diferente que indica el motivo por el que no se pudo proporcionar el objeto ProductDetails.

No todos los códigos de respuesta son errores. En la página de referencia de BillingResponseCode, se proporciona una descripción detallada de cada una de las respuestas que se analizan en esta guía. Estos son algunos ejemplos de códigos de respuesta que no indican errores:

Cuando el código de respuesta sí indica un error, a veces se debe a condiciones transitorias y, por lo tanto, es posible la recuperación. Cuando una llamada a un método de la Biblioteca de Facturación Play muestra un valor BillingResponseCode que indica una condición recuperable, debes reintentar la llamada. En otros casos, las condiciones no se consideran transitorias y, por lo tanto, no se recomienda reintentar la llamada.

Los errores transitorios requieren diferentes estrategias de reintento en función de diferentes factores como si el error ocurre cuando los usuarios están en una sesión (por ejemplo, cuando un usuario pasa por un flujo de compra) o si el error ocurre en segundo plano (por ejemplo, cuando consultas las compras existentes del usuario durante onResume). La sección de estrategias de reintento a continuación proporciona ejemplos de estas diferentes estrategias. Asimismo, en la sección de respuestas recuperables de BillingResult, se recomienda qué estrategia funciona mejor para cada código de respuesta.

Además del código de respuesta, algunas respuestas de error incluyen mensajes para la depuración y el registro.

Estrategias de reintento

Reintento simple

Cuando el usuario está en una sesión, es conveniente implementar una estrategia de reintento simple para que el error interrumpa la experiencia del usuario lo menos posible. En ese caso, recomendamos una estrategia de reintento simple con una cantidad máxima de intentos como condición de salida.

En el siguiente ejemplo, se muestra una estrategia de reintento simple para controlar un error cuando se establece una conexión 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)
  }
  ...
}

Reintento de retirada exponencial

Recomendamos usar la retirada exponencial para las operaciones de la Biblioteca de Facturación Play que suceden en segundo plano y no afectan la experiencia del usuario mientras este se encuentra en una sesión.

Por ejemplo, sería apropiado implementar esta estrategia cuando se procesan compras nuevas porque esta operación puede ocurrir en segundo plano, y el procesamiento de la compra no tiene que ocurrir necesariamente en tiempo real si se produce un error.

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
}

Respuestas recuperables de BillingResult

NETWORK_ERROR (Código de error 12)

Problema

Este error indica que hubo un problema con la conexión de red entre el dispositivo y los sistemas de Play.

Resolución posible

Para la recuperación, usa reintentos simples o retiradas exponenciales, según la acción que activó el error.

SERVICE_TIMEOUT (Código de error -3)

Problema

Este error indica que la solicitud alcanzó el tiempo de espera máximo antes de que Google Play pueda responder. Esto podría deberse, por ejemplo, a una demora en la ejecución de la acción solicitada por la llamada a la Biblioteca de Facturación Play.

Resolución posible

Este suele ser un problema transitorio. Vuelve a intentar la solicitud con una estrategia de reintento simple o de retirada exponencial, según la acción que muestre el error.

A diferencia de SERVICE_DISCONNECTED que se muestra a continuación, la conexión al servicio de Facturación Google Play no se interrumpe y solo debes reintentar la operación de la Biblioteca de Facturación Play que se haya intentado en primer lugar.

SERVICE_DISCONNECTED (Código de error -1)

Problema

Este error fatal indica que se interrumpió la conexión de la app cliente con el servicio de Google Play Store a través de BillingClient.

Resolución posible

Para evitar este error tanto como sea posible, verifica siempre la conexión con los Servicios de Google Play antes de realizar llamadas con la Biblioteca de Facturación Play mediante una llamada a BillingClient.isReady().

Para intentar la recuperación desde SERVICE_DISCONNECTED, la app cliente debe intentar restablecer la conexión mediante BillingClient.startConnection.

Al igual que con SERVICE_TIMEOUT, usa reintentos simples o retiradas exponenciales, según la acción que activó el error.

SERVICE_UNAVAILABLE (Código de error 2)

Nota importante:

A partir de la Biblioteca de Facturación Google Play 6.0.0, SERVICE_UNAVAILABLE ya no se devuelve para problemas de red. Se devuelve cuando el servicio de facturación no está disponible y en reemplazo del código obsoleto SERVICE_TIMEOUT.

Problema

Este error transitorio indica que el servicio de Facturación Google Play no está disponible en este momento. En la mayoría de los casos, esto significa que hay un problema de conexión de red entre el dispositivo cliente y los servicios de Facturación Google Play.

Resolución posible

Este suele ser un problema transitorio. Vuelve a intentar la solicitud con una estrategia de reintento simple o de retirada exponencial, según la acción que muestre el error.

A diferencia de SERVICE_DISCONNECTED, la conexión al servicio de Facturación Google Play no se interrumpe y debes volver a intentar la operación.

BILLING_UNAVAILABLE (Código de error 3)

Problema

Este error indica que se produjo un problema durante la facturación cuando se procesaba la compra del usuario. Los siguientes son algunos ejemplos de cuándo puede ocurrir esto:

  • La app de Play Store del dispositivo del usuario está desactualizada.
  • El usuario se encuentra en un país no admitido.
  • El usuario es un usuario empresarial, y su administrador no permite que los usuarios realicen compras.
  • Google Play no puede aplicar cargos en la forma de pago del usuario. Por ejemplo, es posible que la tarjeta de crédito del usuario haya vencido.

Resolución posible

Es poco probable que los reintentos automáticos sean de ayuda en este caso. Sin embargo, un reintento manual puede ser útil si el usuario soluciona la condición que causó el problema. Por ejemplo, si el usuario actualiza su versión de Play Store a una versión admitida, podría funcionar un reintento manual de la operación inicial.

Si este error ocurre cuando el usuario no está en una sesión, no tiene sentido volver a intentarlo. Cuando recibes un error BILLING_UNAVAILABLE como resultado del flujo de compra, es muy probable que el usuario haya recibido comentarios de Google Play durante el proceso de compra y que esté al tanto de qué problema hubo. En este caso, podrías mostrar un mensaje de error que advierta que hubo un problema junto con el botón "Reintentar" para darle al usuario la opción de volver a intentarlo de forma manual después de resolver el problema.

ERROR (Código de error 6)

Problema

Este es un error fatal que indica que hubo un problema interno con Google Play.

Resolución posible

A veces, los problemas internos de Google Play que generan ERROR son transitorios y se puede implementar un reintento con una retirada exponencial para mitigar el problema. Cuando los usuarios están en una sesión, es preferible un reintento simple.

ITEM_ALREADY_OWNED

Problema

Esta respuesta indica que el usuario de Google Play ya es propietario de la suscripción o del producto de compra única que intenta comprar. En la mayoría de los casos, no es un error transitorio, excepto cuando se debe a que la caché de Google Play está inactiva.

Resolución posible

Para evitar que se produzca este error cuando la causa no sea un problema de la caché, no ofrezcas un producto para comprar cuando el usuario ya lo tenga. Asegúrate de comprobar los derechos del usuario cuando muestres los productos disponibles para la compra y filtra lo que el usuario pueda comprar según corresponda. Cuando la app cliente recibe este error debido a un problema de la caché, el error activa la caché de Google Play para que se actualice con los datos más recientes del backend de Play. Volver a intentarlo después del error debería resolver esta instancia transitoria específica. Llama a BillingClient.queryPurchasesAsync() después de obtener un ITEM_ALREADY_OWNED para verificar si el usuario adquirió el producto. Si no es el caso, implementa una lógica de reintento simple para volver a intentar la compra.

ITEM_NOT_OWNED

Problema

Esta respuesta de compra indica que el usuario de Google Play no es propietario de la suscripción o del producto de compra única que intenta reemplazar, comprar o consumir. Este no es un error transitorio en la mayoría de los casos, excepto cuando se produce porque la caché de Google Play entra en un estado inactivo.

Resolución posible

Cuando se recibe el error debido a un problema de la caché, el error activa la caché de Google Play para que se actualice con los datos más recientes del backend de Play. Volver a intentarlo con una estrategia de reintento simple después del error debería resolver esta instancia transitoria específica. Llama a BillingClient.queryPurchasesAsync() después de obtener un ITEM_NOT_OWNED para verificar si el usuario adquirió el producto. De lo contrario, usa una lógica de reintento simple para volver a intentar la compra.

Respuestas no recuperables de BillingResult

No es posible realizar una recuperación a partir de estos errores con la lógica de reintento.

FEATURE_NOT_SUPPORTED

Problema

Este error no recuperable indica que la función de Facturación Google Play no es compatible con el dispositivo del usuario, probablemente debido a una versión desactualizada de Play Store.

Por ejemplo, es posible que algunos de los dispositivos de tus usuarios no admitan los mensajes desde la app.

Posible mitigación

Usa BillingClient.isFeatureSupported() para comprobar la compatibilidad de funciones antes de realizar la llamada a la Biblioteca de Facturación Play.

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

USER_CANCELED

Problema

El usuario salió de la IU del flujo de facturación.

Resolución posible

Este error es solo informativo y puede fallar de manera controlada.

ITEM_UNAVAILABLE

Problema

La suscripción a la Facturación Google Play o el producto de compra única no están disponibles para que este usuario los compre.

Posible mitigación

Asegúrate de que tu app actualice los detalles del producto mediante queryProductDetailsAsync, como se recomienda. Ten en cuenta la frecuencia con la que cambia el catálogo de productos en la configuración de Play Console para implementar actualizaciones adicionales si es necesario. Solo intenta vender productos en la Facturación Google Play que muestren la información correcta con queryProductDetailsAsync. Verifica la configuración de elegibilidad del producto para detectar inconsistencias. Por ejemplo, podrías estar buscando un producto que solo está disponible para una región distinta de la del usuario que intenta comprar. Para que un producto esté disponible para la compra, debe estar activo y su app debe estar publicada y disponible en el país del usuario.

A veces, especialmente durante la prueba, no hay problemas en la configuración del producto, pero los usuarios aún ven este error. Esto puede deberse a una demora de propagación de los detalles del producto en los servidores de Google. Vuelve a intentarlo más tarde.

DEVELOPER_ERROR

Problema

Este es un error fatal que indica que estás usando una API de forma inadecuada. Por ejemplo, proporcionar parámetros incorrectos a BillingClient.launchBillingFlow puede causar este error.

Resolución posible

Asegúrate de usar correctamente las diferentes llamadas a la Biblioteca de Facturación Play. Además, verifica el mensaje de depuración para obtener más información sobre el error.