From 093ffb27a84e77caefcbf3ab846137404801b842 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 17 Aug 2026 17:22:06 +0100 Subject: [PATCH] [Android] Expose Retry-After on HTTP failures --- platforms/android/README.md | 26 +++++-- platforms/android/lib/api/lib.api | 36 ++++++++-- .../shopify/checkoutkit/CheckoutException.kt | 10 ++- .../shopify/checkoutkit/CheckoutWebView.kt | 65 +++++++++++++---- .../com/shopify/checkoutkit/Configuration.kt | 14 +++- .../com/shopify/checkoutkit/PreloadCache.kt | 37 ++++++++-- .../com/shopify/checkoutkit/PreloadState.kt | 18 ++++- .../com/shopify/checkoutkit/RetryAfter.kt | 36 ++++++++++ .../checkoutkit/CheckoutExceptionAssert.kt | 14 ++++ .../checkoutkit/CheckoutWebViewClientTest.kt | 19 +++++ .../shopify/checkoutkit/ConfigurationTest.kt | 6 ++ .../com/shopify/checkoutkit/InteropTest.java | 12 +++- .../checkoutkit/PreloadObservabilityTest.kt | 72 +++++++++++++++++-- .../com/shopify/checkoutkit/RetryAfterTest.kt | 30 ++++++++ 14 files changed, 351 insertions(+), 44 deletions(-) create mode 100644 platforms/android/lib/src/main/java/com/shopify/checkoutkit/RetryAfter.kt create mode 100644 platforms/android/lib/src/test/java/com/shopify/checkoutkit/RetryAfterTest.kt diff --git a/platforms/android/README.md b/platforms/android/README.md index a8d40ed15..927f8c9df 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -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: @@ -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. | @@ -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 | | --- | --- | --- | @@ -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. | diff --git a/platforms/android/lib/api/lib.api b/platforms/android/lib/api/lib.api index f537565e7..3fc237a24 100644 --- a/platforms/android/lib/api/lib.api +++ b/platforms/android/lib/api/lib.api @@ -88,10 +88,12 @@ public final class com/shopify/checkoutkit/CheckoutException : java/lang/Excepti public fun (Lcom/shopify/checkoutkit/CheckoutErrorCode;Ljava/lang/String;)V public fun (Lcom/shopify/checkoutkit/CheckoutErrorCode;Ljava/lang/String;Ljava/lang/Integer;)V public fun (Lcom/shopify/checkoutkit/CheckoutErrorCode;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Throwable;)V - public synthetic fun (Lcom/shopify/checkoutkit/CheckoutErrorCode;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lcom/shopify/checkoutkit/CheckoutErrorCode;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Throwable;Ljava/lang/Long;)V + public synthetic fun (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 { @@ -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 (I)V + public fun (ILjava/lang/Long;)V + public synthetic fun (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; @@ -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 @@ -669,16 +682,27 @@ public abstract interface class com/shopify/checkoutkit/PreloadStateListener { public final class com/shopify/checkoutkit/Preloading { public fun ()V public fun (Z)V - public synthetic fun (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLcom/shopify/checkoutkit/Preloading$ThrottlePolicy;)V + public synthetic fun (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 (Landroid/content/Context;Ljava/lang/String;Lcom/shopify/checkoutkit/DefaultCheckoutListener;)V diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutException.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutException.kt index b4bae0bfa..062a49a31 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutException.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutException.kt @@ -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 @@ -70,6 +73,7 @@ public class CheckoutException @JvmOverloads constructor( }, message = message, httpStatusCode = statusCode, + retryAfterSeconds = retryAfterSeconds, ) fun network(message: String, cause: Throwable? = null): CheckoutException = diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt index 50a894fcf..a10c8d3f8 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt @@ -270,15 +270,37 @@ 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 { @@ -286,6 +308,7 @@ internal class CheckoutWebView private constructor( request, it.statusCode, it.reasonPhrase.ifBlank { "HTTP ${it.statusCode} Error" }, + retryAfterSeconds, ) } if (isMainFrame) { @@ -358,6 +381,7 @@ internal class CheckoutWebView private constructor( request: WebResourceRequest?, statusCode: Int, errorDescription: String, + retryAfterSeconds: Long?, ) { if (request?.isForMainFrame != true) return @@ -367,7 +391,7 @@ internal class CheckoutWebView private constructor( "statusCode: $statusCode, errorDescription: $errorDescription" ) listener.onCheckoutViewFailedWithError( - CheckoutException.http(statusCode, errorDescription), + CheckoutException.http(statusCode, errorDescription, retryAfterSeconds), ) } } @@ -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, @@ -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 } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Configuration.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Configuration.kt index 04dc93cd9..8cbc5a6c7 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Configuration.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Configuration.kt @@ -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 diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadCache.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadCache.kt index 77c8812f8..0aa7f2f04 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadCache.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadCache.kt @@ -18,6 +18,12 @@ internal data class PreloadKey(val url: String) { internal class PreloadCache( private val expiryScheduler: PreloadExpiryScheduler = HandlerPreloadExpiryScheduler(), ) : DefaultLifecycleObserver { + companion object { + const val THROTTLED_MESSAGE = "Preload throttled until the server-provided Retry-After delay elapses." + private const val LOG_TAG = "PreloadCache" + private const val PRELOAD_TTL_MS = 5 * 60 * 1000L + private const val MILLISECONDS_PER_SECOND = 1_000L + } /** Monotonic, sleep-inclusive clock used to measure preload time-to-live. */ internal open class Clock { @@ -41,6 +47,7 @@ internal class PreloadCache( var clock: Clock = Clock() private var entry: Entry? = null private var observer: CheckoutPreload? = null + private var throttleDeadline: Long? = null var state: PreloadState = PreloadState.Idle private set @@ -83,6 +90,14 @@ internal class PreloadCache( } } + val isThrottleActive: Boolean + get() { + val deadline = throttleDeadline + val active = deadline != null && clock.elapsedRealtime() < deadline + if (!active) throttleDeadline = null + return active + } + fun store(key: PreloadKey, view: CheckoutWebView, lifecycleOwner: LifecycleOwner) { invalidate() val newEntry = Entry( @@ -170,8 +185,23 @@ internal class PreloadCache( * When view is provided, eviction only occurs if it is still the cached entry. Use this for * callbacks from a specific WebView so a stale callback cannot evict a replacement preload. */ - fun evict(state: PreloadState, view: CheckoutWebView? = null) { + fun evict( + state: PreloadState, + view: CheckoutWebView? = null, + suppressPreloadsForSeconds: Long? = null, + ) { if (view != null && entry?.view !== view) return + suppressPreloadsForSeconds?.let { delaySeconds -> + val now = clock.elapsedRealtime() + val delayMillis = delaySeconds + .coerceAtMost(Long.MAX_VALUE / MILLISECONDS_PER_SECOND) + .times(MILLISECONDS_PER_SECOND) + throttleDeadline = if (delayMillis > Long.MAX_VALUE - now) { + Long.MAX_VALUE + } else { + now + delayMillis + } + } invalidate() transition(state) } @@ -224,11 +254,6 @@ internal class PreloadCache( evict(PreloadState.Idle) } } - - private companion object { - private const val LOG_TAG = "PreloadCache" - private const val PRELOAD_TTL_MS = 5 * 60 * 1000L - } } /** Schedules expiry for the current cached preload. */ diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadState.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadState.kt index 8e2a9f914..9ec30e118 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadState.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadState.kt @@ -25,8 +25,22 @@ public sealed class PreloadState { * with exhaustive `when` expressions to handle an additional case when recompiling. */ public sealed class FailureReason { - /** The preload received an HTTP response that prevented it from loading. */ - public data class HttpError(public val statusCode: Int) : FailureReason() + /** + * The preload was throttled and Checkout Kit is suppressing further preload requests + * until the server-provided `Retry-After` delay elapses. + */ + public data object Throttled : FailureReason() + + /** + * The preload received an HTTP response that prevented it from loading. + * + * Under the [Preloading.ThrottlePolicy.PASSTHROUGH] policy, [retryAfterSeconds] is the + * server-provided delay when a throttling response includes a valid `Retry-After` header. + */ + public data class HttpError @JvmOverloads public constructor( + public val statusCode: Int, + public val retryAfterSeconds: Long? = null, + ) : FailureReason() /** Preload navigation failed. */ public data object NavigationFailed : FailureReason() diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/RetryAfter.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/RetryAfter.kt new file mode 100644 index 000000000..72679e0d9 --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/RetryAfter.kt @@ -0,0 +1,36 @@ +package com.shopify.checkoutkit + +import java.text.ParsePosition +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.TimeZone +import kotlin.math.ceil + +internal object RetryAfter { + private const val MILLISECONDS_PER_SECOND = 1_000.0 + private val dateFormats = listOf( + "EEE, dd MMM yyyy HH:mm:ss zzz", + "EEEE, dd-MMM-yy HH:mm:ss zzz", + "EEE MMM d HH:mm:ss yyyy", + ) + + fun seconds(value: String?, nowMillis: Long = System.currentTimeMillis()): Long? { + val normalized = value?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val delay = normalized.toLongOrNull()?.takeIf { it >= 0 } + return delay ?: secondsFromDate(normalized, nowMillis) + } + + private fun secondsFromDate(value: String, nowMillis: Long): Long? = + dateFormats.firstNotNullOfOrNull { format -> + val formatter = SimpleDateFormat(format, Locale.US).apply { + calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT"), Locale.US) + timeZone = TimeZone.getTimeZone("GMT") + isLenient = false + } + val position = ParsePosition(0) + formatter.parse(value, position) + ?.takeIf { position.index == value.length } + ?.let { ceil(maxOf(0L, it.time - nowMillis) / MILLISECONDS_PER_SECOND).toLong() } + } +} diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutExceptionAssert.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutExceptionAssert.kt index 29195587f..215df5b4b 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutExceptionAssert.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutExceptionAssert.kt @@ -41,6 +41,20 @@ class CheckoutExceptionAssert(actual: CheckoutException) : return this } + + fun hasRetryAfterSeconds(retryAfterSeconds: Long): CheckoutExceptionAssert { + isNotNull() + + if (actual.retryAfterSeconds != retryAfterSeconds) { + failWithMessage( + "Expected retry-after seconds <%s>, but was <%s>", + retryAfterSeconds, + actual.retryAfterSeconds, + ) + } + + return this + } } fun noopDefaultCheckoutListener(): DefaultCheckoutListener = object : DefaultCheckoutListener() { diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewClientTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewClientTest.kt index c6e3166f2..408d9d31c 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewClientTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewClientTest.kt @@ -271,6 +271,25 @@ class CheckoutWebViewClientTest { .hasCode(CheckoutErrorCode.HTTP_ERROR) } + @Test + fun `HTTP error includes retry-after response metadata`() { + val mockRequest = mockWebRequest(Uri.parse("https://checkout-sdk.myshopify.com"), true) + val mockResponse = mockWebResourceResponse( + status = 429, + description = "Too Many Requests", + headers = mutableMapOf("retry-after" to "120"), + ) + + triggerOnReceivedHttpError(mockRequest, mockResponse) + + val captor = argumentCaptor() + verify(checkoutWebViewListener).onCheckoutViewFailedWithError(captor.capture()) + assertThat(captor.firstValue) + .hasCode(CheckoutErrorCode.HTTP_ERROR) + .hasHttpStatusCode(429) + .hasRetryAfterSeconds(120) + } + @Test fun `should call event processor calls onCheckoutViewFailedWithError on http error for main frame - 504`() { val mockRequest = mockWebRequest(Uri.parse("https://checkout-sdk.myshopify.com"), true) diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ConfigurationTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ConfigurationTest.kt index 703af83d0..b6f987150 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ConfigurationTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ConfigurationTest.kt @@ -81,6 +81,12 @@ class ConfigurationTest { assertThat(ShopifyCheckoutKit.getConfiguration().preloading.enabled).isTrue() } + @Test + fun `preloading throttle policy defaults to managed`() { + assertThat(ShopifyCheckoutKit.getConfiguration().preloading.throttlePolicy) + .isEqualTo(Preloading.ThrottlePolicy.MANAGED) + } + @Test fun `appearance defaults to storefront`() { assertThat(ShopifyCheckoutKit.getConfiguration().appearance).isEqualTo(CheckoutAppearance.Storefront()) diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java index 24ea0e6bf..73554398d 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java @@ -69,10 +69,18 @@ public void canConstructCheckoutExceptionWithJavaOverloads() { "Checkout request failed", 500 ); + CheckoutException withRetryAfter = new CheckoutException( + CheckoutErrorCode.HTTP_ERROR, + "Checkout request throttled", + 429, + null, + 120L + ); assertThat(withoutOptionalFields.getCode()).isEqualTo(CheckoutErrorCode.UNKNOWN); assertThat(withoutOptionalFields.getHttpStatusCode()).isNull(); assertThat(withHttpStatusCode.getHttpStatusCode()).isEqualTo(500); + assertThat(withRetryAfter.getRetryAfterSeconds()).isEqualTo(120L); } @Test @@ -122,12 +130,14 @@ public void canConfigureTitle() { @Test public void canConfigurePreloading() { ShopifyCheckoutKit.configure(configuration -> { - configuration.setPreloading(new Preloading(false)); + configuration.setPreloading(new Preloading(false, Preloading.ThrottlePolicy.PASSTHROUGH)); }); Configuration configuration = ShopifyCheckoutKit.getConfiguration(); assertThat(configuration.getPreloading().getEnabled()).isFalse(); + assertThat(configuration.getPreloading().getThrottlePolicy()) + .isEqualTo(Preloading.ThrottlePolicy.PASSTHROUGH); } @Test diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/PreloadObservabilityTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/PreloadObservabilityTest.kt index c22e5ecbd..cf0bc526e 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/PreloadObservabilityTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/PreloadObservabilityTest.kt @@ -35,7 +35,12 @@ class PreloadObservabilityTest { webMessageTransport = FakeWebMessageTransport() CheckoutWebView.clearCache() ShadowLooper.shadowMainLooper().idle() - ShopifyCheckoutKit.configure { it.preloading = Preloading(enabled = true) } + ShopifyCheckoutKit.configure { + it.preloading = Preloading( + enabled = true, + throttlePolicy = Preloading.ThrottlePolicy.MANAGED, + ) + } activity = Robolectric.buildActivity(ComponentActivity::class.java).get() } @@ -337,7 +342,11 @@ class PreloadObservabilityTest { } @Test - fun `http error transitions cached preload to failed`() { + fun `managed throttle suppresses preloads until retry-after elapses`() { + var now = 1_000L + CheckoutWebView.cacheClock = object : PreloadCache.Clock() { + override fun elapsedRealtime(): Long = now + } val preload = ShopifyCheckoutKit.preload(url, activity, webMessageTransport)!! ShadowLooper.shadowMainLooper().idle() val view = CheckoutWebView.cachedPreloadViewForTesting()!! @@ -347,8 +356,9 @@ class PreloadObservabilityTest { whenever(it.url).thenReturn(Uri.parse(url)) } val response = mock { - whenever(it.statusCode).thenReturn(500) - whenever(it.reasonPhrase).thenReturn("Internal Server Error") + whenever(it.statusCode).thenReturn(429) + whenever(it.reasonPhrase).thenReturn("Too Many Requests") + whenever(it.responseHeaders).thenReturn(mutableMapOf("Retry-After" to "120")) } shadowOf(view).webViewClient.onReceivedHttpError(view, request, response) ShadowLooper.shadowMainLooper().idle() @@ -356,10 +366,60 @@ class PreloadObservabilityTest { assertThat(preload.state) .isEqualTo( PreloadState.Failed( - PreloadState.FailureReason.HttpError(500), - "HTTP response returned status code 500.", + PreloadState.FailureReason.Throttled, + PreloadCache.THROTTLED_MESSAGE, ), ) + + val suppressed = ShopifyCheckoutKit.preload(url, activity, webMessageTransport)!! + ShadowLooper.shadowMainLooper().idle() + assertThat(suppressed.state).isEqualTo( + PreloadState.Failed( + PreloadState.FailureReason.Throttled, + PreloadCache.THROTTLED_MESSAGE, + ), + ) + assertThat(CheckoutWebView.hasCacheEntryForTesting()).isFalse() + + now += 120_001L + val resumed = ShopifyCheckoutKit.preload(url, activity, webMessageTransport)!! + ShadowLooper.shadowMainLooper().idle() + assertThat(resumed.state).isEqualTo(PreloadState.Loading) + assertThat(CheckoutWebView.hasCacheEntryForTesting()).isTrue() + } + + @Test + fun `passthrough throttle returns http metadata without suppressing another preload`() { + ShopifyCheckoutKit.configure { + it.preloading = Preloading(throttlePolicy = Preloading.ThrottlePolicy.PASSTHROUGH) + } + val preload = ShopifyCheckoutKit.preload(url, activity, webMessageTransport)!! + ShadowLooper.shadowMainLooper().idle() + val view = CheckoutWebView.cachedPreloadViewForTesting()!! + val request = mock { + whenever(it.isForMainFrame).thenReturn(true) + whenever(it.url).thenReturn(Uri.parse(url)) + } + val response = mock { + whenever(it.statusCode).thenReturn(429) + whenever(it.reasonPhrase).thenReturn("Too Many Requests") + whenever(it.responseHeaders).thenReturn(mutableMapOf("Retry-After" to "120")) + } + + shadowOf(view).webViewClient.onReceivedHttpError(view, request, response) + ShadowLooper.shadowMainLooper().idle() + + assertThat(preload.state).isEqualTo( + PreloadState.Failed( + PreloadState.FailureReason.HttpError(429, 120), + "HTTP response returned status code 429.", + ), + ) + + val replacement = ShopifyCheckoutKit.preload(url, activity, webMessageTransport)!! + ShadowLooper.shadowMainLooper().idle() + assertThat(replacement.state).isEqualTo(PreloadState.Loading) + assertThat(CheckoutWebView.hasCacheEntryForTesting()).isTrue() } @Test diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/RetryAfterTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/RetryAfterTest.kt new file mode 100644 index 000000000..aaa75b36d --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/RetryAfterTest.kt @@ -0,0 +1,30 @@ +package com.shopify.checkoutkit + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class RetryAfterTest { + private val nowMillis = 784_111_897_000 + + @Test + fun `parses delay seconds`() { + assertThat(RetryAfter.seconds(" 120 ", nowMillis)).isEqualTo(120) + } + + @Test + fun `parses HTTP date`() { + assertThat(RetryAfter.seconds("Sun, 06 Nov 1994 08:51:47 GMT", nowMillis)).isEqualTo(10) + } + + @Test + fun `past HTTP date returns zero`() { + assertThat(RetryAfter.seconds("Sun, 06 Nov 1994 08:51:27 GMT", nowMillis)).isZero() + } + + @Test + fun `missing or invalid value returns null`() { + assertThat(RetryAfter.seconds(null, nowMillis)).isNull() + assertThat(RetryAfter.seconds("invalid", nowMillis)).isNull() + assertThat(RetryAfter.seconds("-1", nowMillis)).isNull() + } +}