Treat a 410 for an IAP token as not found

This commit is contained in:
ravi-signal 2025-03-07 15:24:33 -06:00 committed by GitHub
parent d1c9dff2c5
commit 469955aec9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 48 additions and 15 deletions

View File

@ -142,7 +142,7 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
// We've already acknowledged this purchase on a previous attempt, nothing to do // We've already acknowledged this purchase on a previous attempt, nothing to do
return CompletableFuture.completedFuture(null); return CompletableFuture.completedFuture(null);
} }
return executeAsync(pub -> pub.purchases().subscriptions() return executeTokenOperation(pub -> pub.purchases().subscriptions()
.acknowledge(packageName, productId, purchaseToken, new SubscriptionPurchasesAcknowledgeRequest())); .acknowledge(packageName, productId, purchaseToken, new SubscriptionPurchasesAcknowledgeRequest()));
} }
@ -217,7 +217,7 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
} }
final SubscriptionPurchaseLineItem purchase = getLineItem(subscription); final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);
return executeAsync(pub -> return executeTokenOperation(pub ->
pub.purchases().subscriptions().cancel(packageName, purchase.getProductId(), purchaseToken)); pub.purchases().subscriptions().cancel(packageName, purchase.getProductId(), purchaseToken));
}); });
} }
@ -339,26 +339,37 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
return CompletableFuture.supplyAsync(() -> { return CompletableFuture.supplyAsync(() -> {
try { try {
return apiCall.req(androidPublisher).execute(); return apiCall.req(androidPublisher).execute();
} catch (GoogleJsonResponseException e) {
if (e.getStatusCode() == Response.Status.NOT_FOUND.getStatusCode()) {
throw ExceptionUtils.wrap(new SubscriptionException.NotFound());
}
logger.warn("Unexpected HTTP status code {} from androidpublisher: {}", e.getStatusCode(), e.getDetails(), e);
throw ExceptionUtils.wrap(e);
} catch (HttpResponseException e) {
if (e.getStatusCode() == Response.Status.NOT_FOUND.getStatusCode()) {
throw ExceptionUtils.wrap(new SubscriptionException.NotFound());
}
logger.warn("Unexpected HTTP status code {} from androidpublisher", e.getStatusCode(), e);
throw ExceptionUtils.wrap(e);
} catch (IOException e) { } catch (IOException e) {
throw ExceptionUtils.wrap(e); throw ExceptionUtils.wrap(e);
} }
}, executor); }, executor);
} }
/**
* Asynchronously execute a synchronous API call on a purchaseToken, mapping expected errors to the appropriate
* {@link SubscriptionException}
*
* @param apiCall An API call that operates on a purchaseToken
* @param <R> The result of the API call
* @return A stage that completes with the result of the API call
*/
private <R> CompletableFuture<R> executeTokenOperation(final ApiCall<R> apiCall) {
return executeAsync(apiCall)
.exceptionally(ExceptionUtils.exceptionallyHandler(HttpResponseException.class, e -> {
if (e.getStatusCode() == Response.Status.NOT_FOUND.getStatusCode()
|| e.getStatusCode() == Response.Status.GONE.getStatusCode()) {
throw ExceptionUtils.wrap(new SubscriptionException.NotFound());
}
final String details = e instanceof GoogleJsonResponseException
? ((GoogleJsonResponseException) e).getDetails().toString()
: "";
logger.warn("Unexpected HTTP status code {} from androidpublisher: {}", e.getStatusCode(), details, e);
throw ExceptionUtils.wrap(e);
}));
}
private CompletableFuture<SubscriptionPurchaseV2> lookupSubscription(final String purchaseToken) { private CompletableFuture<SubscriptionPurchaseV2> lookupSubscription(final String purchaseToken) {
return executeAsync(publisher -> publisher.purchases().subscriptionsv2().get(packageName, purchaseToken)); return executeTokenOperation(publisher -> publisher.purchases().subscriptionsv2().get(packageName, purchaseToken));
} }
private long productIdToLevel(final String productId) { private long productIdToLevel(final String productId) {

View File

@ -15,6 +15,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import com.google.api.client.http.HttpResponseException;
import com.google.api.services.androidpublisher.AndroidPublisher; import com.google.api.services.androidpublisher.AndroidPublisher;
import com.google.api.services.androidpublisher.model.AutoRenewingPlan; import com.google.api.services.androidpublisher.model.AutoRenewingPlan;
import com.google.api.services.androidpublisher.model.BasePlan; import com.google.api.services.androidpublisher.model.BasePlan;
@ -33,11 +34,14 @@ import java.util.Map;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.whispersystems.textsecuregcm.storage.SubscriptionException; import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil; import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;
import org.whispersystems.textsecuregcm.util.MockUtils; import org.whispersystems.textsecuregcm.util.MockUtils;
@ -257,4 +261,22 @@ class GooglePlayBillingManagerTest {
} }
public static Stream<Arguments> tokenErrors() {
return Stream.of(
Arguments.of(404, SubscriptionException.NotFound.class),
Arguments.of(410, SubscriptionException.NotFound.class),
Arguments.of(400, HttpResponseException.class)
);
}
@ParameterizedTest
@MethodSource
public void tokenErrors(final int httpStatus, Class<? extends Exception> expected) throws IOException {
final HttpResponseException mockException = mock(HttpResponseException.class);
when(mockException.getStatusCode()).thenReturn(httpStatus);
when(subscriptionsv2Get.execute()).thenThrow(mockException);
CompletableFutureTestUtil.assertFailsWithCause(expected,
googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN));
}
} }