Skip to content

Commit

Permalink
Handle networking errors (#43)
Browse files Browse the repository at this point in the history
* Add handling for networking errors

* Fix issue and add tests

* Remove subscribe helper and just use backend directly

* Remove unused error classes

* Extract code to functions to avoid repetition and simplify

* Another cleanup as suggested in PR
  • Loading branch information
tonidero authored Jan 26, 2024
1 parent a26c285 commit ef05d68
Show file tree
Hide file tree
Showing 10 changed files with 557 additions and 93 deletions.
205 changes: 180 additions & 25 deletions src/entities/errors.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,192 @@
import { StatusCodes } from "http-status-codes";

export enum ErrorCode {
UnknownError = 0,
UserCancelledError = 1,
StoreProblemError = 2,
PurchaseNotAllowedError = 3,
PurchaseInvalidError = 4,
ProductNotAvailableForPurchaseError = 5,
ProductAlreadyPurchasedError = 6,
ReceiptAlreadyInUseError = 7,
InvalidReceiptError = 8,
MissingReceiptFileError = 9,
NetworkError = 10,
InvalidCredentialsError = 11,
UnexpectedBackendResponseError = 12,
InvalidAppUserIdError = 14,
OperationAlreadyInProgressError = 15,
UnknownBackendError = 16,
InvalidAppleSubscriptionKeyError = 17,
IneligibleError = 18,
InsufficientPermissionsError = 19,
PaymentPendingError = 20,
InvalidSubscriberAttributesError = 21,
LogOutWithAnonymousUserError = 22,
ConfigurationError = 23,
UnsupportedError = 24,
EmptySubscriberAttributesError = 25,
CustomerInfoError = 28,
SignatureVerificationError = 36,
}

export class ErrorCodeUtils {
static getPublicMessage(errorCode: ErrorCode): string {
switch (errorCode) {
case ErrorCode.UnknownError:
return "Unknown error.";
case ErrorCode.UserCancelledError:
return "Purchase was cancelled.";
case ErrorCode.StoreProblemError:
return "There was a problem with the store.";
case ErrorCode.PurchaseNotAllowedError:
return "The device or user is not allowed to make the purchase.";
case ErrorCode.PurchaseInvalidError:
return "One or more of the arguments provided are invalid.";
case ErrorCode.ProductNotAvailableForPurchaseError:
return "The product is not available for purchase.";
case ErrorCode.ProductAlreadyPurchasedError:
return "This product is already active for the user.";
case ErrorCode.ReceiptAlreadyInUseError:
return "There is already another active subscriber using the same receipt.";
case ErrorCode.InvalidReceiptError:
return "The receipt is not valid.";
case ErrorCode.MissingReceiptFileError:
return "The receipt is missing.";
case ErrorCode.NetworkError:
return "Error performing request.";
case ErrorCode.InvalidCredentialsError:
return "There was a credentials issue. Check the underlying error for more details.";
case ErrorCode.UnexpectedBackendResponseError:
return "Received unexpected response from the backend.";
case ErrorCode.InvalidAppUserIdError:
return "The app user id is not valid.";
case ErrorCode.OperationAlreadyInProgressError:
return "The operation is already in progress.";
case ErrorCode.UnknownBackendError:
return "There was an unknown backend error.";
case ErrorCode.InvalidAppleSubscriptionKeyError:
return (
"Apple Subscription Key is invalid or not present. " +
"In order to provide subscription offers, you must first generate a subscription key. " +
"Please see https://docs.revenuecat.com/docs/ios-subscription-offers for more info."
);
case ErrorCode.IneligibleError:
return "The User is ineligible for that action.";
case ErrorCode.InsufficientPermissionsError:
return "App does not have sufficient permissions to make purchases.";
case ErrorCode.PaymentPendingError:
return "The payment is pending.";
case ErrorCode.InvalidSubscriberAttributesError:
return "One or more of the attributes sent could not be saved.";
case ErrorCode.LogOutWithAnonymousUserError:
return "Called logOut but the current user is anonymous.";
case ErrorCode.ConfigurationError:
return "There is an issue with your configuration. Check the underlying error for more details.";
case ErrorCode.UnsupportedError:
return (
"There was a problem with the operation. Looks like we doesn't support " +
"that yet. Check the underlying error for more details."
);
case ErrorCode.EmptySubscriberAttributesError:
return "A request for subscriber attributes returned none.";
case ErrorCode.CustomerInfoError:
return "There was a problem related to the customer info.";
case ErrorCode.SignatureVerificationError:
return "Request failed signature verification. Please see https://rev.cat/trusted-entitlements for more info.";
}
}

static getErrorCodeForBackendErrorCode(
backendErrorCode: BackendErrorCode,
): ErrorCode {
switch (backendErrorCode) {
case BackendErrorCode.BackendStoreProblem:
return ErrorCode.StoreProblemError;
case BackendErrorCode.BackendCannotTransferPurchase:
return ErrorCode.ReceiptAlreadyInUseError;
case BackendErrorCode.BackendInvalidReceiptToken:
return ErrorCode.InvalidReceiptError;
case BackendErrorCode.BackendInvalidPlayStoreCredentials:
case BackendErrorCode.BackendInvalidAuthToken:
case BackendErrorCode.BackendInvalidAPIKey:
return ErrorCode.InvalidCredentialsError;
case BackendErrorCode.BackendInvalidPaymentModeOrIntroPriceNotProvided:
case BackendErrorCode.BackendProductIdForGoogleReceiptNotProvided:
return ErrorCode.PurchaseInvalidError;
case BackendErrorCode.BackendEmptyAppUserId:
return ErrorCode.InvalidAppUserIdError;
case BackendErrorCode.BackendPlayStoreQuotaExceeded:
return ErrorCode.StoreProblemError;
case BackendErrorCode.BackendPlayStoreInvalidPackageName:
case BackendErrorCode.BackendInvalidPlatform:
return ErrorCode.ConfigurationError;
case BackendErrorCode.BackendPlayStoreGenericError:
return ErrorCode.StoreProblemError;
case BackendErrorCode.BackendUserIneligibleForPromoOffer:
return ErrorCode.IneligibleError;
case BackendErrorCode.BackendInvalidSubscriberAttributes:
case BackendErrorCode.BackendInvalidSubscriberAttributesBody:
return ErrorCode.InvalidSubscriberAttributesError;
case BackendErrorCode.BackendInvalidAppStoreSharedSecret:
case BackendErrorCode.BackendInvalidAppleSubscriptionKey:
case BackendErrorCode.BackendBadRequest:
case BackendErrorCode.BackendInternalServerError:
return ErrorCode.UnexpectedBackendResponseError;
case BackendErrorCode.BackendProductIDsMalformed:
return ErrorCode.UnsupportedError;
}
}

static convertCodeToBackendErrorCode(code: number): BackendErrorCode | null {
if (code in BackendErrorCode) {
return code as BackendErrorCode;
} else {
return null;
}
}
}

export enum BackendErrorCode {
BackendInvalidPlatform = 7000,
BackendStoreProblem = 7101,
BackendCannotTransferPurchase = 7102,
BackendInvalidReceiptToken = 7103,
BackendInvalidAppStoreSharedSecret = 7104,
BackendInvalidPaymentModeOrIntroPriceNotProvided = 7105,
BackendProductIdForGoogleReceiptNotProvided = 7106,
BackendInvalidPlayStoreCredentials = 7107,
BackendInternalServerError = 7110,
BackendEmptyAppUserId = 7220,
BackendInvalidAuthToken = 7224,
BackendInvalidAPIKey = 7225,
BackendBadRequest = 7226,
BackendPlayStoreQuotaExceeded = 7229,
BackendPlayStoreInvalidPackageName = 7230,
BackendPlayStoreGenericError = 7231,
BackendUserIneligibleForPromoOffer = 7232,
BackendInvalidAppleSubscriptionKey = 7234,
BackendInvalidSubscriberAttributes = 7263,
BackendInvalidSubscriberAttributesBody = 7264,
BackendProductIDsMalformed = 7662,
}

export class PurchasesError extends Error {
static getForBackendError(
backendErrorCode: BackendErrorCode,
backendErrorMessage: string | null,
): PurchasesError {
const errorCode =
ErrorCodeUtils.getErrorCodeForBackendErrorCode(backendErrorCode);
return new PurchasesError(
errorCode,
ErrorCodeUtils.getPublicMessage(errorCode),
backendErrorMessage,
);
}

constructor(
public readonly errorCode: ErrorCode,
message?: string,
public readonly underlyingErrorMessage?: string | null,
) {
super(message);
}
Expand All @@ -17,26 +195,3 @@ export class PurchasesError extends Error {
return `PurchasesError(code: ${ErrorCode[this.errorCode]}, message: ${this.message})`;
};
}

export class ServerError extends Error {
constructor(
public readonly statusCode: number,
message?: string | undefined,
) {
super(message);
}
}

export class UnknownServerError extends ServerError {
constructor() {
super(StatusCodes.INTERNAL_SERVER_ERROR, "An unknown error occurred.");
}
}

export class InvalidInputDataError extends ServerError {}

export class AlreadySubscribedError extends ServerError {}

export class PaymentGatewayError extends ServerError {}

export class ConcurrentSubscriberAttributeUpdateError extends ServerError {}
17 changes: 0 additions & 17 deletions src/helpers/subscribe-helper.ts

This file was deleted.

7 changes: 6 additions & 1 deletion src/networking/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
} from "./endpoints";
import { SubscriberResponse } from "./responses/subscriber-response";
import { SubscribeResponse } from "./responses/subscribe-response";
import { SubscribeRequestBody } from "../helpers/subscribe-helper";
import { ProductsResponse } from "./responses/products-response";
import { EntitlementsResponse } from "./responses/entitlements-response";

Expand Down Expand Up @@ -56,6 +55,12 @@ export class Backend {
productId: string,
email: string,
): Promise<SubscribeResponse> {
type SubscribeRequestBody = {
app_user_id: string;
product_id: string;
email: string;
};

return await performRequest<SubscribeRequestBody, SubscribeResponse>(
new SubscribeEndpoint(),
this.API_KEY,
Expand Down
6 changes: 6 additions & 0 deletions src/networking/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const RC_BILLING_PATH = "/rcbilling/v1";

interface Endpoint {
method: HttpMethodType;
name: string;
url(): string;
}

Expand All @@ -18,6 +19,7 @@ export class GetOfferingsEndpoint implements Endpoint {
}

method: HttpMethodType = "GET";
name: string = "getOfferings";

url(): string {
return `${RC_ENDPOINT}${SUBSCRIBERS_PATH}/${this.appUserId}/offerings`;
Expand All @@ -26,6 +28,7 @@ export class GetOfferingsEndpoint implements Endpoint {

export class SubscribeEndpoint implements Endpoint {
method: HttpMethodType = "POST";
name: string = "subscribe";

url(): string {
return `${RC_ENDPOINT}${RC_BILLING_PATH}/subscribe`;
Expand All @@ -34,6 +37,7 @@ export class SubscribeEndpoint implements Endpoint {

export class GetProductsEndpoint implements Endpoint {
method: HttpMethodType = "GET";
name: string = "getProducts";
private readonly appUserId: string;
private readonly productIds: string[];

Expand All @@ -49,6 +53,7 @@ export class GetProductsEndpoint implements Endpoint {

export class GetCustomerInfoEndpoint implements Endpoint {
method: HttpMethodType = "GET";
name: string = "getCustomerInfo";
private readonly appUserId: string;

constructor(appUserId: string) {
Expand All @@ -62,6 +67,7 @@ export class GetCustomerInfoEndpoint implements Endpoint {

export class GetEntitlementsEndpoint implements Endpoint {
method: HttpMethodType = "GET";
name: string = "getEntitlements";
private readonly appUserId: string;

constructor(appUserId: string) {
Expand Down
50 changes: 45 additions & 5 deletions src/networking/http-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { SupportedEndpoint } from "./endpoints";
import { ServerError } from "../entities/errors";
import {
BackendErrorCode,
ErrorCode,
ErrorCodeUtils,
PurchasesError,
} from "../entities/errors";
import { VERSION } from "../helpers/constants";
import { StatusCodes } from "http-status-codes";

export async function performRequest<RequestBody, ResponseType>(
endpoint: SupportedEndpoint,
Expand All @@ -14,14 +20,48 @@ export async function performRequest<RequestBody, ResponseType>(
body: getBody(body),
});

// TODO: Improve error handling
if (response.status >= 400) {
throw new ServerError(response.status, await response.text());
}
await handleErrors(response, endpoint);

return (await response.json()) as ResponseType; // TODO: Validate response is correct.
}

async function handleErrors(response: Response, endpoint: SupportedEndpoint) {
const statusCode = response.status;
if (statusCode >= StatusCodes.INTERNAL_SERVER_ERROR) {
throwUnknownError(endpoint, statusCode, await response.text());
} else if (statusCode >= StatusCodes.BAD_REQUEST) {
const errorBody = await response.json();
const errorBodyString = errorBody ? JSON.stringify(errorBody) : null;
const backendErrorCodeNumber: number | null = errorBody?.code;
const backendErrorMessage: string | null = errorBody?.message;
if (backendErrorCodeNumber != null) {
const backendErrorCode: BackendErrorCode | null =
ErrorCodeUtils.convertCodeToBackendErrorCode(backendErrorCodeNumber);
if (backendErrorCode == null) {
throwUnknownError(endpoint, statusCode, errorBodyString);
} else {
throw PurchasesError.getForBackendError(
backendErrorCode,
backendErrorMessage,
);
}
} else {
throwUnknownError(endpoint, statusCode, errorBodyString);
}
}
}

function throwUnknownError(
endpoint: SupportedEndpoint,
statusCode: number,
errorBody: string | null,
) {
throw new PurchasesError(
ErrorCode.UnknownBackendError,
`Unknown backend error. Request: ${endpoint.name}. Status code: ${statusCode}. Body: ${errorBody}.`,
);
}

function getBody<RequestBody>(body?: RequestBody): string | null {
if (body == null) {
return null;
Expand Down
Loading

0 comments on commit ef05d68

Please sign in to comment.