Handle 429s from play API and add subscription docs

This commit is contained in:
Ravi Khadiwala 2025-07-07 13:36:24 -05:00 committed by ravi-signal
parent 0745cabc87
commit 80c11e7eda
8 changed files with 154 additions and 12 deletions

View File

@ -15,6 +15,7 @@ import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import io.swagger.v3.oas.annotations.ExternalDocumentation;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@ -66,6 +67,7 @@ import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration;
import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.PaymentTime;
@ -218,6 +220,19 @@ public class SubscriptionController {
@DELETE
@Path("/{subscriberId}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Cancel a subscription", description = """
Cancels any current subscription at the end of the current subscription period.
Note: Apple IAP subscriptions do not support server-side cancellation, so this method should only be called after
cancelling a subscription from storekit to keep server data up to date.
""")
@ApiResponse(responseCode = "200", description = "All subscriptions cancelled")
@ApiResponse(responseCode = "403", description = "Account authentication is present")
@ApiResponse(responseCode = "404", description = "subscriberId is not found or malformed")
@ApiResponse(responseCode = "400", description = "The associated subscription is not a type that can be cancelled")
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<Response> deleteSubscriber(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
@ -230,6 +245,16 @@ public class SubscriptionController {
@Path("/{subscriberId}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create/refresh a subscriber", description = """
Creates a subscriber record if it does not exist, otherwise refreshes its last access time.
Subscribers MUST periodically hit this endpoint to update the access time on the subscription record. Subscribers
SHOULD attempt to make an update call approximately every 3 days. Not accessing this endpoint for an extended
period of time will result in the subscription being canceled.
""")
@ApiResponse(responseCode = "200", description = "The subscriber was successfully created or refreshed")
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "subscriberId is malformed")
public CompletableFuture<Response> updateSubscriber(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
@ -429,7 +454,9 @@ public class SubscriptionController {
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed or the specified transaction does not exist")
@ApiResponse(responseCode = "409", description = "subscriberId is already linked to a processor that does not support appstore payments. Delete this subscriberId and use a new one.")
@ApiResponse(responseCode = "429", description = "Rate limit exceeded.")
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<SetSubscriptionLevelSuccessResponse> setAppStoreSubscription(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId,
@ -471,6 +498,9 @@ public class SubscriptionController {
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed or the purchaseToken does not exist")
@ApiResponse(responseCode = "409", description = "subscriberId is already linked to a processor that does not support Play Billing. Delete this subscriberId and use a new one.")
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<SetSubscriptionLevelSuccessResponse> setPlayStoreSubscription(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId,
@ -625,6 +655,9 @@ public class SubscriptionController {
@ApiResponse(responseCode = "200", description = "The subscriberId exists", content = @Content(schema = @Schema(implementation = GetSubscriptionInformationResponse.class)))
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed")
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<Response> getSubscriptionInformation(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
@ -650,16 +683,64 @@ public class SubscriptionController {
.orElseGet(() -> Response.ok(new GetSubscriptionInformationResponse(null, null)).build()));
}
public record GetReceiptCredentialsRequest(@NotEmpty byte[] receiptCredentialRequest) {
public record GetReceiptCredentialsRequest(
@Schema(description = "A ReceiptCredentialRequest encoded in standard base64 with padding")
@NotEmpty byte[] receiptCredentialRequest) {
}
public record GetReceiptCredentialsResponse(@NotEmpty byte[] receiptCredentialResponse) {
public record GetReceiptCredentialsResponse(
@Schema(description = "A ReceiptCredentialResponse encoded in standard base64 with padding")
@NotEmpty byte[] receiptCredentialResponse) {
}
@POST
@Path("/{subscriberId}/receipt_credentials")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create receipt credentials", description = """
Create a receipt from a valid payment invoice that can be used to obtain an entitlement
This request is repeatable so long as the ReceiptCredentialRequest remains the same. Clients should use the same
ReceiptCredentialRequest value until they attempt to redeem the resulting ReceiptCredentialPresentation. After
this point, the ReceiptCredentialRequest MUST NOT be reused or you may not be able to redeem a valid payment
invoice. Clients SHOULD retry requests at this endpoint with the same ReceiptCredentialRequest value until
receiving a response. After receiving a response, clients should then compute the ReceiptCredentialPresentation
and redeem it at the receipt redemption endpoint. Once the first attempt is made there, the same
ReceiptCredentialRequest MUST NOT be used again to request receipt credentials.
Note that you may in fact redeem TWO or more invoices for the same ReceiptCredentialRequest while retrying this
operation if a later invoice gets paid while you are retrying. However, the returned receipt is always for the
latest invoice, so it will have the latest expiration possible and no entitlement time will be lost. The important
thing is not to reuse ReceiptCredentialRequest after you have started attempting to redeem the associated
ReceiptCredentialPresentation. Then you may produce a ReceiptCredentialPresentation for a later invoice that
cannot be redeemed.
Clients MUST validate that the generated receipt credential's level and expiration matches their expectations.
""")
@ApiResponse(responseCode = "200", description = "Successfully created receipt", content = @Content(schema = @Schema(implementation = GetReceiptCredentialsResponse.class)))
@ApiResponse(responseCode = "204", description = "No invoice has been issued for this subscription OR invoice is in 'open' state")
@ApiResponse(responseCode = "400", description = "Bad ReceiptCredentialRequest")
@ApiResponse(responseCode = "402", description = "Invoice is in any state other than 'open' or 'paid'. May include chargeFailure details in body.",
content = @Content(schema = @Schema(
nullable = true,
example = """
{
"chargeFailure": {
"code": "incorrect_account_holder_name",
"message": "The transaction can't be processed because your customer's account information is missing [...]",
"outcomeNetworkStatus": "declined_by_network",
"outcomeReason": "generic_decline",
"outcomeType": "issuer_declined"
}
}
""",
implementation = SubscriptionExceptionMapper.ChargeFailureResponse.class)))
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "subscriberId is not found OR malformed OR no subscription setup on the subscriber id")
@ApiResponse(responseCode = "409", description = "latest paid receipt on subscription was already redeemed for a receipt credential but with a different receipt credential request")
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<Response> createSubscriptionReceiptCredentials(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,

View File

@ -13,11 +13,14 @@ import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import java.util.Map;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
public class SubscriptionExceptionMapper implements ExceptionMapper<SubscriptionException> {
@VisibleForTesting
public static final int PROCESSOR_ERROR_STATUS_CODE = 440;
public record ChargeFailureResponse(String processor, ChargeFailure chargeFailure) {}
@Override
public Response toResponse(final SubscriptionException exception) {
@ -31,17 +34,14 @@ public class SubscriptionExceptionMapper implements ExceptionMapper<Subscription
}
if (exception instanceof SubscriptionException.ProcessorException e) {
return Response.status(PROCESSOR_ERROR_STATUS_CODE)
.entity(Map.of(
"processor", e.getProcessor().name(),
"chargeFailure", e.getChargeFailure()
))
.entity(new ChargeFailureResponse(e.getProcessor().name(), e.getChargeFailure()))
.type(MediaType.APPLICATION_JSON_TYPE)
.build();
}
if (exception instanceof SubscriptionException.ChargeFailurePaymentRequired e) {
return Response
.status(Response.Status.PAYMENT_REQUIRED)
.entity(Map.of("chargeFailure", e.getChargeFailure()))
.entity(new ChargeFailureResponse(e.getProcessor().name(), e.getChargeFailure()))
.type(MediaType.APPLICATION_JSON_TYPE)
.build();
}

View File

@ -102,16 +102,23 @@ public class SubscriptionException extends Exception {
public static class ChargeFailurePaymentRequired extends SubscriptionException {
private final PaymentProvider processor;
private final ChargeFailure chargeFailure;
public ChargeFailurePaymentRequired(final ChargeFailure chargeFailure) {
public ChargeFailurePaymentRequired(final PaymentProvider processor, final ChargeFailure chargeFailure) {
super(null, null);
this.processor = processor;
this.chargeFailure = chargeFailure;
}
public PaymentProvider getProcessor() {
return processor;
}
public ChargeFailure getChargeFailure() {
return chargeFailure;
}
}
public static class ProcessorException extends SubscriptionException {

View File

@ -633,7 +633,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
if (subscriptionStatus.equals(SubscriptionStatus.ACTIVE) || subscriptionStatus.equals(SubscriptionStatus.PAST_DUE)) {
throw ExceptionUtils.wrap(new SubscriptionException.ReceiptRequestedForOpenPayment());
}
throw ExceptionUtils.wrap(new SubscriptionException.ChargeFailurePaymentRequired(createChargeFailure(transaction)));
throw ExceptionUtils.wrap(new SubscriptionException.ChargeFailurePaymentRequired(getProvider(), createChargeFailure(transaction)));
}
final Instant paidAt = transaction.getSubscriptionDetails().getBillingPeriodStartDate().toInstant();

View File

@ -43,6 +43,7 @@ import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.storage.PaymentTime;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
@ -362,10 +363,14 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
|| e.getStatusCode() == Response.Status.GONE.getStatusCode()) {
throw ExceptionUtils.wrap(new SubscriptionException.NotFound());
}
if (e.getStatusCode() == Response.Status.TOO_MANY_REQUESTS.getStatusCode()) {
throw ExceptionUtils.wrap(new RateLimitExceededException(null));
}
final String details = e instanceof GoogleJsonResponseException
? ((GoogleJsonResponseException) e).getDetails().toString()
: "";
logger.warn("Unexpected HTTP status code {} from androidpublisher: {}", e.getStatusCode(), details, e);
logger.warn("Unexpected HTTP status code {} from androidpublisher: {}", e.getStatusCode(), details);
throw ExceptionUtils.wrap(e);
}));
}

View File

@ -645,7 +645,8 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
// If the charge object has a failure reason we can present to the user, create a detailed exception
.filter(charge -> charge.getFailureCode() != null || charge.getFailureMessage() != null)
.<SubscriptionException> map(charge -> new SubscriptionException.ChargeFailurePaymentRequired(createChargeFailure(charge)))
.<SubscriptionException> map(charge ->
new SubscriptionException.ChargeFailurePaymentRequired(getProvider(), createChargeFailure(charge)))
// Otherwise, return a generic payment required error
.orElseGet(() -> new SubscriptionException.PaymentRequired())));

View File

@ -992,6 +992,44 @@ class SubscriptionControllerTest {
verify(PLAY_MANAGER, times(1)).cancelAllActiveSubscriptions(oldPurchaseToken);
}
@Test
void createReceiptChargeFailure() throws InvalidInputException, VerificationFailedException {
final byte[] subscriberUserAndKey = new byte[32];
Arrays.fill(subscriberUserAndKey, (byte) 1);
final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);
when(CLOCK.instant()).thenReturn(Instant.now());
when(SUBSCRIPTIONS.get(any(), any()))
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(Subscriptions.Record.from(
Arrays.copyOfRange(subscriberUserAndKey, 0, 16),
Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),
Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),
Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()),
Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID,
b(new ProcessorCustomer("customer", PaymentProvider.STRIPE).toDynamoBytes()),
Subscriptions.KEY_SUBSCRIPTION_ID, s("subscriptionId"))))));
when(STRIPE_MANAGER.getReceiptItem(any()))
.thenReturn(CompletableFuture.failedFuture(new SubscriptionException.ChargeFailurePaymentRequired(
PaymentProvider.STRIPE,
new ChargeFailure("card_declined", "Insufficient funds", null, null, null))));
final ReceiptCredentialRequest receiptRequest = new ClientZkReceiptOperations(
ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext(
new ReceiptSerial(new byte[ReceiptSerial.SIZE])).getRequest();
final Response response = RESOURCE_EXTENSION
.target(String.format("/v1/subscription/%s/receipt_credentials", subscriberId))
.request()
.post(Entity.json(new SubscriptionController.GetReceiptCredentialsRequest(receiptRequest.serialize())));
assertThat(response.getStatus()).isEqualTo(402);
final Map responseMap = response.readEntity(Map.class);
assertThat(responseMap.get("processor")).isEqualTo("STRIPE");
assertThat(responseMap.get("chargeFailure")).asInstanceOf(
InstanceOfAssertFactories.map(String.class, Object.class))
.extracting("code")
.isEqualTo("card_declined");
}
@ParameterizedTest
@CsvSource({"5, P45D", "201, P13D"})
public void createReceiptCredential(long level, Duration expectedExpirationWindow)

View File

@ -42,6 +42,7 @@ 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.MethodSource;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;
import org.whispersystems.textsecuregcm.util.MockUtils;
@ -191,6 +192,15 @@ class GooglePlayBillingManagerTest {
verifyNoInteractions(cancel);
}
@Test
public void handle429() throws IOException {
final HttpResponseException mockException = mock(HttpResponseException.class);
when(mockException.getStatusCode()).thenReturn(429);
when(subscriptionsv2Get.execute()).thenThrow(mockException);
CompletableFutureTestUtil.assertFailsWithCause(
RateLimitExceededException.class, googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN));
}
@Test
public void getReceiptUnacknowledged() throws IOException {
when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()