Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions platforms/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,23 @@ A successful background preload normally transitions from `Loading` to `Ready`.
| `Expired` | The cached preload reached its lifetime and was discarded before use. |
| `Failed(reason)` | Checkout navigation, web content, or an HTTP response failed while preloading. |

By default, a 429 response with a valid `Retry-After` header produces a `Throttled` failure.
Checkout Kit suppresses further preload requests until the server-provided delay elapses; it does
not retry automatically. Presentation is never suppressed.

To manage preload backoff in your application instead, use the `PASSTHROUGH` throttle policy:

```kotlin
ShopifyCheckoutKit.configure {
it.preloading = Preloading(
throttlePolicy = Preloading.ThrottlePolicy.PASSTHROUGH,
)
}
```

Under `PASSTHROUGH`, the failure is `HttpError(429, retryAfterSeconds)` and Checkout Kit does
not suppress subsequent preload requests.

`preload` returns `null` when preloading is disabled, the activity is finishing or destroyed, or the installed WebView does not support the required WebMessageListener API.

Checkout Kit can reuse a matching preloaded checkout when `present` is called later:
Expand Down Expand Up @@ -290,7 +307,7 @@ ShopifyCheckoutKit.configure {
| `appearance` | `CheckoutAppearance.Storefront()` | Use the storefront's web checkout branding, or use the Checkout Kit style with `App(Automatic)`, `App(Light)`, or `App(Dark)`. |
| `sheet` | `CheckoutSheetOptions()` | Customize native sheet presentation such as snap points, dismissal behavior, corner radius, title alignment, toolbar elevation, close icon styling, and the optional drag handle. |
| `logLevel` | `LogLevel.WARN` | SDK logging verbosity. Use `LogLevel.DEBUG` during integration. |
| `preloading` | `Preloading(enabled = true)` | Enables best-effort checkout preloading before presentation. |
| `preloading` | `Preloading(enabled = true, throttlePolicy = MANAGED)` | Enables best-effort checkout preloading and controls whether Checkout Kit enforces server-provided backoff. |
| `title` | `null` | Runtime override for the checkout sheet header title. When `null`, the SDK uses the localized `checkout_web_view_title` string resource. |
| `allowedMessageOrigins` | `emptySet()` | Extra origins allowed to send checkout protocol messages. |

Expand Down Expand Up @@ -498,8 +515,9 @@ The public `CheckoutProtocol` descriptors are typed wrappers over UCP-backed che

A checkout lifecycle failure is delivered as a `CheckoutException` to `onFail` or
`onCheckoutFailed`. It has a stable `code`, diagnostic `message`, optional
`httpStatusCode`, and the optional native `cause`. Use the stable code for recovery
and analytics. Use diagnostic text and causes only for debugging and logging.
`httpStatusCode`, optional server-provided `retryAfterSeconds`, and the optional native `cause`.
Use the stable code for recovery and analytics. Use diagnostic text and causes only for debugging
and logging.

| `CheckoutErrorCode` | Meaning | Suggested app action |
| --- | --- | --- |
Expand All @@ -508,7 +526,7 @@ and analytics. Use diagnostic text and causes only for debugging and logging.
| `CART_EXPIRED` | The cart or checkout session is no longer available. | Create a new cart and retry. |
| `CART_COMPLETED` | The cart has already completed checkout. | Clear or create a new cart. |
| `INVALID_CART` | The cart cannot continue checkout. | Create a new cart and retry. |
| `HTTP_ERROR` | Checkout returned an HTTP error response. `httpStatusCode` is available. | Inspect `httpStatusCode`; retry only when it makes sense for your app. |
| `HTTP_ERROR` | Checkout returned an HTTP error response. `httpStatusCode` and, when supplied by the server, `retryAfterSeconds` are available. | Inspect `httpStatusCode`; do not retry before `retryAfterSeconds`, and retry only when it makes sense for your app. |
| `NETWORK_ERROR` | Checkout navigation failed before an HTTP response was available. | Offer a retry when connectivity is available. |
| `WEB_VIEW_NOT_SUPPORTED` | The device WebView provider lacks a required capability. | WebView support is widely available, but offer a browser fallback when it is unavailable. |
| `WEB_CONTENT_PROCESS_TERMINATED` | The WebView renderer was terminated or crashed. | Dismiss the current presentation, destroy an embedded `ShopifyCheckout` after removal, and let the buyer retry with a new checkout. |
Expand Down
36 changes: 30 additions & 6 deletions platforms/android/lib/api/lib.api
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,12 @@ public final class com/shopify/checkoutkit/CheckoutException : java/lang/Excepti
public fun <init> (Lcom/shopify/checkoutkit/CheckoutErrorCode;Ljava/lang/String;)V
public fun <init> (Lcom/shopify/checkoutkit/CheckoutErrorCode;Ljava/lang/String;Ljava/lang/Integer;)V
public fun <init> (Lcom/shopify/checkoutkit/CheckoutErrorCode;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Throwable;)V
public synthetic fun <init> (Lcom/shopify/checkoutkit/CheckoutErrorCode;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Lcom/shopify/checkoutkit/CheckoutErrorCode;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Throwable;Ljava/lang/Long;)V
public synthetic fun <init> (Lcom/shopify/checkoutkit/CheckoutErrorCode;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Throwable;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getCode ()Lcom/shopify/checkoutkit/CheckoutErrorCode;
public final fun getHttpStatusCode ()Ljava/lang/Integer;
public fun getMessage ()Ljava/lang/String;
public final fun getRetryAfterSeconds ()Ljava/lang/Long;
}

public abstract interface class com/shopify/checkoutkit/CheckoutHandle {
Expand Down Expand Up @@ -611,10 +613,14 @@ public abstract class com/shopify/checkoutkit/PreloadState$FailureReason {

public final class com/shopify/checkoutkit/PreloadState$FailureReason$HttpError : com/shopify/checkoutkit/PreloadState$FailureReason {
public fun <init> (I)V
public fun <init> (ILjava/lang/Long;)V
public synthetic fun <init> (ILjava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()I
public final fun copy (I)Lcom/shopify/checkoutkit/PreloadState$FailureReason$HttpError;
public static synthetic fun copy$default (Lcom/shopify/checkoutkit/PreloadState$FailureReason$HttpError;IILjava/lang/Object;)Lcom/shopify/checkoutkit/PreloadState$FailureReason$HttpError;
public final fun component2 ()Ljava/lang/Long;
public final fun copy (ILjava/lang/Long;)Lcom/shopify/checkoutkit/PreloadState$FailureReason$HttpError;
public static synthetic fun copy$default (Lcom/shopify/checkoutkit/PreloadState$FailureReason$HttpError;ILjava/lang/Long;ILjava/lang/Object;)Lcom/shopify/checkoutkit/PreloadState$FailureReason$HttpError;
public fun equals (Ljava/lang/Object;)Z
public final fun getRetryAfterSeconds ()Ljava/lang/Long;
public final fun getStatusCode ()I
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
Expand All @@ -634,6 +640,13 @@ public final class com/shopify/checkoutkit/PreloadState$FailureReason$ProtocolEr
public fun toString ()Ljava/lang/String;
}

public final class com/shopify/checkoutkit/PreloadState$FailureReason$Throttled : com/shopify/checkoutkit/PreloadState$FailureReason {
public static final field INSTANCE Lcom/shopify/checkoutkit/PreloadState$FailureReason$Throttled;
public fun equals (Ljava/lang/Object;)Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/shopify/checkoutkit/PreloadState$FailureReason$WebContentUnavailable : com/shopify/checkoutkit/PreloadState$FailureReason {
public static final field INSTANCE Lcom/shopify/checkoutkit/PreloadState$FailureReason$WebContentUnavailable;
public fun equals (Ljava/lang/Object;)Z
Expand Down Expand Up @@ -669,16 +682,27 @@ public abstract interface class com/shopify/checkoutkit/PreloadStateListener {
public final class com/shopify/checkoutkit/Preloading {
public fun <init> ()V
public fun <init> (Z)V
public synthetic fun <init> (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (ZLcom/shopify/checkoutkit/Preloading$ThrottlePolicy;)V
public synthetic fun <init> (ZLcom/shopify/checkoutkit/Preloading$ThrottlePolicy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Z
public final fun copy (Z)Lcom/shopify/checkoutkit/Preloading;
public static synthetic fun copy$default (Lcom/shopify/checkoutkit/Preloading;ZILjava/lang/Object;)Lcom/shopify/checkoutkit/Preloading;
public final fun component2 ()Lcom/shopify/checkoutkit/Preloading$ThrottlePolicy;
public final fun copy (ZLcom/shopify/checkoutkit/Preloading$ThrottlePolicy;)Lcom/shopify/checkoutkit/Preloading;
public static synthetic fun copy$default (Lcom/shopify/checkoutkit/Preloading;ZLcom/shopify/checkoutkit/Preloading$ThrottlePolicy;ILjava/lang/Object;)Lcom/shopify/checkoutkit/Preloading;
public fun equals (Ljava/lang/Object;)Z
public final fun getEnabled ()Z
public final fun getThrottlePolicy ()Lcom/shopify/checkoutkit/Preloading$ThrottlePolicy;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/shopify/checkoutkit/Preloading$ThrottlePolicy : java/lang/Enum {
public static final field MANAGED Lcom/shopify/checkoutkit/Preloading$ThrottlePolicy;
public static final field PASSTHROUGH Lcom/shopify/checkoutkit/Preloading$ThrottlePolicy;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Lcom/shopify/checkoutkit/Preloading$ThrottlePolicy;
public static fun values ()[Lcom/shopify/checkoutkit/Preloading$ThrottlePolicy;
}

public final class com/shopify/checkoutkit/ShopifyCheckout : android/widget/FrameLayout {
public static final field Companion Lcom/shopify/checkoutkit/ShopifyCheckout$Companion;
public fun <init> (Landroid/content/Context;Ljava/lang/String;Lcom/shopify/checkoutkit/DefaultCheckoutListener;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,25 @@ public enum class CheckoutErrorCode {
* or [CheckoutPresentation.onFail].
*
* Use [code] for application behavior. Use [message] and [cause] only for debugging and logging.
* [httpStatusCode] is present only when an HTTP response caused failure. Your app owns recovery
* actions such as retrying, recreating a cart, authenticating a buyer, and reopening checkout.
* [httpStatusCode] is present only when an HTTP response caused failure. [retryAfterSeconds]
* contains the server-provided delay when one is available. Your app owns recovery actions such
* as retrying, recreating a cart, authenticating a buyer, and reopening checkout.
*
* @property code Stable code for this failure.
* @property message Diagnostic description. Do not use it as a stable recovery or analytics key.
* @property httpStatusCode HTTP status for an HTTP-response failure, otherwise `null`.
* @param cause Native diagnostic cause, when one is available.
* @property retryAfterSeconds Server-provided delay before another request should be attempted.
*/
public class CheckoutException @JvmOverloads constructor(
public val code: CheckoutErrorCode,
override val message: String,
public val httpStatusCode: Int? = null,
cause: Throwable? = null,
public val retryAfterSeconds: Long? = null,
) : Exception(message, cause) {
internal companion object {
fun http(statusCode: Int, message: String): CheckoutException =
fun http(statusCode: Int, message: String, retryAfterSeconds: Long? = null): CheckoutException =
CheckoutException(
code = if (statusCode == HttpURLConnection.HTTP_GONE) {
CheckoutErrorCode.CART_EXPIRED
Expand All @@ -70,6 +73,7 @@ public class CheckoutException @JvmOverloads constructor(
},
message = message,
httpStatusCode = statusCode,
retryAfterSeconds = retryAfterSeconds,
)

fun network(message: String, cause: Throwable? = null): CheckoutException =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,22 +270,45 @@ internal class CheckoutWebView private constructor(
errorResponse: WebResourceResponse?
) {
val isMainFrame = request?.isForMainFrame == true
val retryAfterHeader = errorResponse?.responseHeaders
?.entries
?.firstOrNull { it.key.equals("Retry-After", ignoreCase = true) }
?.value
val retryAfterSeconds = RetryAfter.seconds(retryAfterHeader)
if (isMainFrame) {
val statusCode = errorResponse?.statusCode ?: 0
preloadCache.evict(
PreloadState.Failed(
PreloadState.FailureReason.HttpError(statusCode),
"HTTP response returned status code $statusCode.",
),
view = this@CheckoutWebView,
)
val managedThrottle = isPreloadRequest &&
!isPresented &&
statusCode == HTTP_TOO_MANY_REQUESTS &&
retryAfterSeconds != null &&
ShopifyCheckoutKit.configuration.preloading.throttlePolicy ==
Preloading.ThrottlePolicy.MANAGED
if (managedThrottle) {
preloadCache.evict(
PreloadState.Failed(
PreloadState.FailureReason.Throttled,
PreloadCache.THROTTLED_MESSAGE,
),
view = this@CheckoutWebView,
suppressPreloadsForSeconds = retryAfterSeconds,
)
} else {
preloadCache.evict(
PreloadState.Failed(
PreloadState.FailureReason.HttpError(statusCode, retryAfterSeconds),
"HTTP response returned status code $statusCode.",
),
view = this@CheckoutWebView,
)
}
}
super.onReceivedHttpError(view, request, errorResponse)
errorResponse?.let {
handleHttpError(
request,
it.statusCode,
it.reasonPhrase.ifBlank { "HTTP ${it.statusCode} Error" },
retryAfterSeconds,
)
}
if (isMainFrame) {
Expand Down Expand Up @@ -358,6 +381,7 @@ internal class CheckoutWebView private constructor(
request: WebResourceRequest?,
statusCode: Int,
errorDescription: String,
retryAfterSeconds: Long?,
) {
if (request?.isForMainFrame != true) return

Expand All @@ -367,7 +391,7 @@ internal class CheckoutWebView private constructor(
"statusCode: $statusCode, errorDescription: $errorDescription"
)
listener.onCheckoutViewFailedWithError(
CheckoutException.http(statusCode, errorDescription),
CheckoutException.http(statusCode, errorDescription, retryAfterSeconds),
)
}
}
Expand All @@ -386,6 +410,7 @@ internal class CheckoutWebView private constructor(
companion object {
private const val SHOPIFY_PURPOSE_HEADER = "Shopify-Purpose"
private const val PREFETCH_PURPOSE = "prefetch"
private const val HTTP_TOO_MANY_REQUESTS = 429
private val RETRYABLE_CHECKOUT_ERROR_CODES = setOf(
ERROR_TIMEOUT,
ERROR_CONNECT,
Expand Down Expand Up @@ -440,14 +465,26 @@ internal class CheckoutWebView private constructor(
}
else -> try {
runOnUiThreadBlocking(activity) {
val view = CheckoutWebView(activity, webMessageTransport)
val handle = CheckoutPreload(preloadCache)
view.apply {
loadCheckout(url, isPreload = true)
log.d(LOG_TAG, "Pausing preloaded WebView.")
onPause()
if (ShopifyCheckoutKit.configuration.preloading.throttlePolicy ==
Preloading.ThrottlePolicy.MANAGED &&
preloadCache.isThrottleActive
) {
preloadCache.evict(
PreloadState.Failed(
PreloadState.FailureReason.Throttled,
PreloadCache.THROTTLED_MESSAGE,
),
)
} else {
val view = CheckoutWebView(activity, webMessageTransport)
view.apply {
loadCheckout(url, isPreload = true)
log.d(LOG_TAG, "Pausing preloaded WebView.")
onPause()
}
preloadCache.store(PreloadKey.forUrl(url), view, activity)
}
preloadCache.store(PreloadKey.forUrl(url), view, activity)
handle.listener = listener
handle
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,19 @@ public data class Configuration internal constructor(
internal fun Configuration.resolveCheckoutTitle(context: Context): String =
title ?: context.getString(R.string.checkout_web_view_title)

public data class Preloading(
public data class Preloading @JvmOverloads constructor(
public val enabled: Boolean = true,
)
public val throttlePolicy: ThrottlePolicy = ThrottlePolicy.MANAGED,
) {
/** Controls how Checkout Kit handles preload throttling responses. */
public enum class ThrottlePolicy {
/** Respect `Retry-After` and suppress preload requests until the delay elapses. */
MANAGED,

/** Surface the HTTP failure without suppressing subsequent preload requests. */
PASSTHROUGH,
}
}

public enum class LogLevel {
DEBUG, WARN, ERROR, NONE
Expand Down
Loading
Loading