Skip to content

Commit

Permalink
feat: Payment State Polling Error Handling (#772)
Browse files Browse the repository at this point in the history
* feat: Payment State Polling Error Handling

Per THES-216:
- Adding 5 failures for HTTP issues, then we show a banner
- Fatal Errors fail and show banner immediately

Also:
- Documentation Updates
- Constants for Error Handling
- Ability to generate an API Error without automatically throwing

* chore: Updating Payment State Polling to use a Custom Routine

* chore: Payment State Polling Reducer Tests

* chore: Polling Reducer and Action Testing
  • Loading branch information
grmartin committed Jul 7, 2023
1 parent b655061 commit 98d32f4
Show file tree
Hide file tree
Showing 14 changed files with 563 additions and 80 deletions.
6 changes: 6 additions & 0 deletions src/feedback/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ export const MESSAGE_TYPES = {
WARNING: 'warning',
ERROR: 'error',
};

export const ERROR_CODES = {
FALLBACK: 'fallback-error',
BASKET_CHANGED: 'basket-changed-error-message',
TRANSACTION_DECLINED: 'transaction-declined-message',
};
8 changes: 4 additions & 4 deletions src/feedback/data/sagas.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { put } from 'redux-saga/effects';

import { logError, logInfo } from '@edx/frontend-platform/logging';
import { addMessage, clearMessages } from './actions';
import { MESSAGE_TYPES } from './constants';
import { ERROR_CODES, MESSAGE_TYPES } from './constants';

export function* handleErrors(e, clearExistingMessages) {
if (clearExistingMessages) {
Expand All @@ -11,15 +11,15 @@ export function* handleErrors(e, clearExistingMessages) {

// If this doesn't contain anything we understand, add a fallback error message
if (e.errors === undefined && e.fieldErrors === undefined && e.messages === undefined) {
yield put(addMessage('fallback-error', null, {}, MESSAGE_TYPES.ERROR));
yield put(addMessage(ERROR_CODES.FALLBACK, null, {}, MESSAGE_TYPES.ERROR));
}
if (e.errors !== undefined) {
for (let i = 0; i < e.errors.length; i++) { // eslint-disable-line no-plusplus
const error = e.errors[i];
if (error.code === 'basket-changed-error-message') {
if (error.code === ERROR_CODES.BASKET_CHANGED) {
yield put(addMessage(error.code, error.userMessage, {}, MESSAGE_TYPES.ERROR));
} else if (error.data === undefined && error.messageType === null) {
yield put(addMessage('transaction-declined-message', error.userMessage, {}, MESSAGE_TYPES.ERROR));
yield put(addMessage(ERROR_CODES.TRANSACTION_DECLINED, error.userMessage, {}, MESSAGE_TYPES.ERROR));
} else {
yield put(addMessage(error.code, error.userMessage, error.data, error.messageType));
}
Expand Down
4 changes: 2 additions & 2 deletions src/payment/PaymentProcessingModal.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ const basketStoreGenerator = (paymentState = PAYMENT_STATE.DEFAULT, keepPolling
/** state specific to paymentStatePolling */
paymentStatePolling: {
// eslint-disable-next-line object-shorthand
keepPolling: keepPolling, // TODO: GRM: FIX both debugging items (this is one)
counter: 5, // debugging
keepPolling: keepPolling,
counter: 5,
},
},
clientSecret: { isClientSecretProcessing: false, clientSecretId: '' },
Expand Down
3 changes: 3 additions & 0 deletions src/payment/data/__snapshots__/redux.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Object {
"paymentState": "checkout",
"paymentStatePolling": Object {
"keepPolling": false,
"retriesLeft": 5,
},
"products": Array [],
"redirect": false,
Expand All @@ -35,6 +36,7 @@ Object {
"paymentState": "checkout",
"paymentStatePolling": Object {
"keepPolling": false,
"retriesLeft": 5,
},
"products": Array [],
"redirect": false,
Expand All @@ -61,6 +63,7 @@ Object {
"paymentState": "checkout",
"paymentStatePolling": Object {
"keepPolling": false,
"retriesLeft": 5,
},
"products": Array [],
"redirect": false,
Expand Down
13 changes: 5 additions & 8 deletions src/payment/data/actions.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createRoutine } from 'redux-saga-routines';
import { createCustomRoutine } from './utils';

// Routines are action + action creator pairs in a series.
// Actions adhere to the flux standard action format.
Expand All @@ -10,8 +11,11 @@ import { createRoutine } from 'redux-saga-routines';
// fetchBasket.SUCCESS | fetchBasket.success()
// fetchBasket.FAILURE | fetchBasket.failure()
// fetchBasket.FULFILL | fetchBasket.fulfill()
// fetchBasket.REQUEST | fetchBasket.request()
// fetchBasket.<CUSTOM> | fetchBasket.<custom>()
//
// Created with redux-saga-routines

export const fetchCaptureKey = createRoutine('FETCH_CAPTURE_KEY');
export const fetchClientSecret = createRoutine('FETCH_CLIENT_SECRET');
export const submitPayment = createRoutine('SUBMIT_PAYMENT');
Expand All @@ -20,7 +24,7 @@ export const fetchActiveOrder = createRoutine('FETCH_ACTIVE_ORDER');
export const addCoupon = createRoutine('ADD_COUPON');
export const removeCoupon = createRoutine('REMOVE_COUPON');
export const updateQuantity = createRoutine('UPDATE_QUANTITY');
export const pollPaymentState = createRoutine('UPDATE_PAYMENT_STATE');
export const pollPaymentState = createCustomRoutine('UPDATE_PAYMENT_STATE', ['RECEIVED']);

// Actions and their action creators
export const BASKET_DATA_RECEIVED = 'BASKET_DATA_RECEIVED';
Expand Down Expand Up @@ -76,10 +80,3 @@ export const clientSecretDataReceived = clientSecret => ({
type: CLIENT_SECRET_DATA_RECEIVED,
payload: clientSecret,
});

export const PAYMENT_STATE_DATA_RECEIVED = 'PAYMENT_STATE_DATA_RECEIVED';

export const paymentStateDataReceived = paymentState => ({
type: PAYMENT_STATE_DATA_RECEIVED,
payload: paymentState,
});
18 changes: 18 additions & 0 deletions src/payment/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,21 @@ export const POLLING_PAYMENT_STATES = [
PAYMENT_STATE.PENDING,
PAYMENT_STATE.HTTP_ERROR,
];

/**
* Default Delay between rounds of Payment State Polling
*
* @type {number}
*
* > Note: This can be configured by setting `PAYMENT_STATE_POLLING_DELAY_SECS` in your config.
*/
export const DEFAULT_PAYMENT_STATE_POLLING_DELAY_SECS = 5;

/**
* Default number of maximum HTTP errors before give up
*
* @type {number}
*
* > Note: This can be configured by setting `PAYMENT_STATE_POLLING_MAX_ERRORS` in your config.
*/
export const DEFAULT_PAYMENT_STATE_POLLING_MAX_ERRORS = 5;
62 changes: 50 additions & 12 deletions src/payment/data/handleRequestError.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
import { logError, logInfo } from '@edx/frontend-platform/logging';
import { camelCaseObject } from './utils';
import { ERROR_CODES } from '../../feedback/data/constants';

/**
* @class RequestError
*
* @property {AxiosResponse} [response]
* @property {string?} [code]
* @property {string?} [type]
*
* @extends Error
*/
/**
* @typedef ApiErrorMessage
*
* @property {string?} [error_code]
* @property {string?} [user_message]
* @property {string?} [message_type]
*
*/

/**
* @throws
*/
function handleFieldErrors(errors) {
const fieldErrors = Object.entries(errors).map(([name, value]) => ({
code: value.error_code ? value.error_code : null,
Expand All @@ -13,7 +35,13 @@ function handleFieldErrors(errors) {
throw validationError;
}

function handleApiErrors(errors) {
/**
* Process API Errors and Generate an Error Object
* @param {ApiErrorMessage[]} errors
* @param {boolean} shouldThrow
* @throws {Error} (Conditionally, but usually)
*/
export function generateApiError(errors, shouldThrow = true) {
const apiErrors = errors.map(err => ({
code: err.error_code ? err.error_code : null,
userMessage: err.user_message ? err.user_message : null,
Expand All @@ -22,9 +50,16 @@ function handleApiErrors(errors) {

const apiError = new Error();
apiError.errors = apiErrors;
throw apiError;

if (shouldThrow) { throw apiError; }

return apiError;
}

/**
* @param {*} messages
* @throws
*/
function handleApiMessages(messages) {
const apiError = new Error();
apiError.messages = camelCaseObject(messages);
Expand All @@ -39,9 +74,8 @@ function handleApiMessages(messages) {
*
* Field errors will be packaged with a fieldErrors field usable by the client.
*
* @param error The original error object.
* @param unpackFunction (Optional) A function to use to unpack the field errors as a replacement
* for the default.
* @param {RequestError|Error} error The original error object.
* @throws
*/
export default function handleRequestError(error) {
// Validation errors
Expand All @@ -53,7 +87,7 @@ export default function handleRequestError(error) {
// API errors
if (error.response && error.response.data.errors !== undefined) {
logInfo('API Errors', error.response.data.errors);
handleApiErrors(error.response.data.errors);
generateApiError(error.response.data.errors);
}

// API messages
Expand All @@ -65,7 +99,7 @@ export default function handleRequestError(error) {
// Single API error
if (error.response && error.response.data.error_code) {
logInfo('API Error', error.response.data.error_code);
handleApiErrors([
generateApiError([
{
error_code: error.response.data.error_code,
user_message: error.response.data.user_message,
Expand All @@ -76,9 +110,9 @@ export default function handleRequestError(error) {
// SKU mismatch error
if (error.response && error.response.data.sku_error) {
logInfo('SKU Error', error.response.data.sku_error);
handleApiErrors([
generateApiError([
{
error_code: 'basket-changed-error-message',
error_code: ERROR_CODES.BASKET_CHANGED,
user_message: 'error',
},
]);
Expand All @@ -87,9 +121,9 @@ export default function handleRequestError(error) {
// Basket already purchased
if (error.code === 'payment_intent_unexpected_state' && error.type === 'invalid_request_error') {
logInfo('Basket Changed Error', error.code);
handleApiErrors([
generateApiError([
{
error_code: 'basket-changed-error-message',
error_code: ERROR_CODES.BASKET_CHANGED,
user_message: 'error',
},
]);
Expand All @@ -100,7 +134,11 @@ export default function handleRequestError(error) {
throw error;
}

// Processes API errors and converts them to error objects the sagas can use.
/**
* Processes API errors and converts them to error objects the sagas can use.
* @param requestError
* @throws
*/
export function handleApiError(requestError) {
try {
// Always throws an error:
Expand Down
59 changes: 51 additions & 8 deletions src/payment/data/reducers.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { combineReducers } from 'redux';

import { getConfig } from '@edx/frontend-platform';
import {
BASKET_DATA_RECEIVED,
BASKET_PROCESSING,
Expand All @@ -8,7 +9,6 @@ import {
CLIENT_SECRET_DATA_RECEIVED,
CLIENT_SECRET_PROCESSING,
MICROFORM_STATUS,
PAYMENT_STATE_DATA_RECEIVED,
fetchBasket,
submitPayment,
fetchCaptureKey,
Expand All @@ -18,7 +18,11 @@ import {
} from './actions';

import { DEFAULT_STATUS } from '../checkout/payment-form/flex-microform/constants';
import { PAYMENT_STATE, POLLING_PAYMENT_STATES } from './constants';
import {
DEFAULT_PAYMENT_STATE_POLLING_MAX_ERRORS,
PAYMENT_STATE,
POLLING_PAYMENT_STATES,
} from './constants';
import { chainReducers } from './utils';

/**
Expand All @@ -29,6 +33,11 @@ const paymentStatePollingInitialState = {
* @see paymentProcessStatusIsPollingSelector
*/
keepPolling: false,
/**
* This is replaceable by a configuration value. (`PAYMENT_STATE_POLLING_MAX_ERRORS`),
* however, this is our default.
*/
retriesLeft: DEFAULT_PAYMENT_STATE_POLLING_MAX_ERRORS,
};

/**
Expand All @@ -44,9 +53,9 @@ const basketInitialState = {
redirect: false,
isBasketProcessing: false,
products: [],
/** Modified by both getActiveOrder and paymentStatePolling */
/** Modified by both getActiveOrder and pollPaymentState */
paymentState: PAYMENT_STATE.DEFAULT,
/** state specific to paymentStatePolling */
/** state specific to pollPaymentState */
paymentStatePolling: paymentStatePollingInitialState,
};

Expand Down Expand Up @@ -146,7 +155,25 @@ const clientSecret = (state = clientSecretInitialState, action = null) => {
return state;
};

const paymentState = (state = basketInitialState, action = null) => {
/**
* Payment State (Polling) Reducer
*
* > NOTE:
* > The `PaymentProcessingModal` relies on the basket's `paymentState`, where as, the inner structure
* > The Inner paymentStatePolling object in the basket is used only by the saga handler/worker, `handlePaymentState`
*
* @param {*} state A basket State Representation
* @param action The Pending Action/Message for this Handler
* @returns {*} A basket State Representation
*
* @see paymentStatePollingInitialState
* @see PaymentProcessingModal
* @see basketInitialState
* @see handlePaymentState
*/
export const paymentState = (state = basketInitialState, action = null) => {
// noinspection JSUnresolvedReference
const maxErrors = getConfig().PAYMENT_STATE_POLLING_MAX_ERRORS || paymentStatePollingInitialState.retriesLeft;
const shouldPoll = (payState) => POLLING_PAYMENT_STATES.includes(payState);

if (action !== null && action !== undefined) {
Expand All @@ -157,6 +184,18 @@ const paymentState = (state = basketInitialState, action = null) => {
paymentStatePolling: {
...state.paymentStatePolling,
keepPolling: shouldPoll(state.paymentState),
retriesLeft: maxErrors,
},
};

case pollPaymentState.FAILURE:
return {
...state,
paymentState: null,
paymentStatePolling: {
...state.paymentStatePolling,
keepPolling: false,
retriesLeft: maxErrors,
},
};

Expand All @@ -166,19 +205,23 @@ const paymentState = (state = basketInitialState, action = null) => {
paymentStatePolling: {
...state.paymentStatePolling,
keepPolling: false,
retriesLeft: maxErrors,
},
};

case PAYMENT_STATE_DATA_RECEIVED:
case pollPaymentState.RECEIVED: {
const isHttpError = action.payload.state === PAYMENT_STATE.HTTP_ERROR;
const currRetriesLeft = (isHttpError ? state.paymentStatePolling.retriesLeft - 1 : maxErrors);
return {
...state,
paymentState: action.payload.state,
paymentStatePolling: {
...state.paymentStatePolling,
keepPolling: shouldPoll(action.payload.state),
// ...action.payload, // debugging
keepPolling: currRetriesLeft > 0 && shouldPoll(action.payload.state),
retriesLeft: currRetriesLeft,
},
};
}

default:
}
Expand Down
Loading

0 comments on commit 98d32f4

Please sign in to comment.