Skip to content

Commit

Permalink
feat: Payment State Polling Error Handling
Browse files Browse the repository at this point in the history
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
  • Loading branch information
grmartin committed Jul 5, 2023
1 parent b655061 commit d37cd30
Show file tree
Hide file tree
Showing 10 changed files with 177 additions and 55 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
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 @@ -8,6 +8,7 @@ Object {
"loading": true,
"paymentState": "checkout",
"paymentStatePolling": Object {
"errorCount": 5,
"keepPolling": false,
},
"products": Array [],
Expand All @@ -34,6 +35,7 @@ Object {
"loading": true,
"paymentState": "checkout",
"paymentStatePolling": Object {
"errorCount": 5,
"keepPolling": false,
},
"products": Array [],
Expand All @@ -60,6 +62,7 @@ Object {
"loading": true,
"paymentState": "checkout",
"paymentStatePolling": Object {
"errorCount": 5,
"keepPolling": false,
},
"products": Array [],
Expand Down
16 changes: 16 additions & 0 deletions src/payment/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,19 @@ export const POLLING_PAYMENT_STATES = [
PAYMENT_STATE.PENDING,
PAYMENT_STATE.HTTP_ERROR,
];

/**
*
* @type {number}
*
* This can be configured by setting `PAYMENT_STATE_POLLING_DELAY_SECS` in your config.
*/
export const DEFAULT_PAYMENT_STATE_POLLING_DELAY_SECS = 5;

/**
*
* @type {number}
*
* 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
33 changes: 31 additions & 2 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 @@ -18,7 +19,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 +34,11 @@ const paymentStatePollingInitialState = {
* @see paymentProcessStatusIsPollingSelector
*/
keepPolling: false,
/**
* This is replaceable by a configuration value. (`PAYMENT_STATE_POLLING_MAX_ERRORS`),
* however, this is our default.
*/
errorCount: DEFAULT_PAYMENT_STATE_POLLING_MAX_ERRORS,
};

/**
Expand Down Expand Up @@ -147,16 +157,33 @@ const clientSecret = (state = clientSecretInitialState, action = null) => {
};

const paymentState = (state = basketInitialState, action = null) => {
// noinspection JSUnresolvedReference
const maxErrors = getConfig().PAYMENT_STATE_POLLING_MAX_ERRORS || paymentStatePollingInitialState.errorCount;
const shouldPoll = (payState) => POLLING_PAYMENT_STATES.includes(payState);

if (action !== null && action !== undefined) {
switch (action.type) {
// The modal relies on the basket's paymentState
// The Inner paymentStatePolling object is used only by the saga handler/worker

case pollPaymentState.TRIGGER:
return {
...state,
paymentStatePolling: {
...state.paymentStatePolling,
keepPolling: shouldPoll(state.paymentState),
errorCount: maxErrors,
},
};

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

Expand All @@ -166,6 +193,7 @@ const paymentState = (state = basketInitialState, action = null) => {
paymentStatePolling: {
...state.paymentStatePolling,
keepPolling: false,
errorCount: maxErrors,
},
};

Expand All @@ -176,7 +204,8 @@ const paymentState = (state = basketInitialState, action = null) => {
paymentStatePolling: {
...state.paymentStatePolling,
keepPolling: shouldPoll(action.payload.state),
// ...action.payload, // debugging
errorCount: (action.payload.state === PAYMENT_STATE.HTTP_ERROR
? state.paymentStatePolling.errorCount - 1 : maxErrors),
},
};

Expand Down
4 changes: 3 additions & 1 deletion src/payment/data/redux.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from './actions';
import { currencyDisclaimerSelector, paymentSelector } from './selectors';
import { localizedCurrencySelector } from './utils';
import { PAYMENT_STATE } from './constants';
import { DEFAULT_PAYMENT_STATE_POLLING_MAX_ERRORS, PAYMENT_STATE } from './constants';

jest.mock('universal-cookie', () => {
class MockCookies {
Expand Down Expand Up @@ -114,6 +114,7 @@ describe('redux tests', () => {
isRedirect: false,
paymentState: PAYMENT_STATE.DEFAULT,
paymentStatePolling: {
errorCount: DEFAULT_PAYMENT_STATE_POLLING_MAX_ERRORS,
keepPolling: false,
},
});
Expand All @@ -138,6 +139,7 @@ describe('redux tests', () => {
isRedirect: true, // this is also now true.
paymentState: PAYMENT_STATE.DEFAULT,
paymentStatePolling: {
errorCount: DEFAULT_PAYMENT_STATE_POLLING_MAX_ERRORS,
keepPolling: false,
},
});
Expand Down
Loading

0 comments on commit d37cd30

Please sign in to comment.