diff --git a/src/common-components/APIFailureMessage.jsx b/src/common-components/APIFailureMessage.jsx index 9710ad31..2febce1a 100644 --- a/src/common-components/APIFailureMessage.jsx +++ b/src/common-components/APIFailureMessage.jsx @@ -3,18 +3,38 @@ import { Alert } from '@edx/paragon'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; import messages from './messages'; +import { API_RATELIMIT_ERROR, INTERNAL_SERVER_ERROR } from '../data/constants'; const APIFailureMessage = (props) => { - const { intl, header } = props; + const { intl, header, errorCode } = props; + let errorMessage = null; + let id = null; + + switch (errorCode) { + case INTERNAL_SERVER_ERROR: + id = INTERNAL_SERVER_ERROR; + errorMessage = intl.formatMessage(messages['internal.server.error.message']); + break; + case API_RATELIMIT_ERROR: + id = API_RATELIMIT_ERROR; + errorMessage = intl.formatMessage(messages['server.ratelimit.error.message']); + break; + default: + break; + } return ( -
+
- + {header} - {intl.formatMessage(messages['internal.server.error.message'])} +
    +
  • + {errorMessage} +
  • +
@@ -24,6 +44,7 @@ const APIFailureMessage = (props) => { APIFailureMessage.propTypes = { intl: intlShape.isRequired, header: PropTypes.string.isRequired, + errorCode: PropTypes.string.isRequired, }; export default injectIntl(APIFailureMessage); diff --git a/src/common-components/messages.jsx b/src/common-components/messages.jsx index 4673b789..24b024d6 100644 --- a/src/common-components/messages.jsx +++ b/src/common-components/messages.jsx @@ -28,6 +28,11 @@ const messages = defineMessages({ defaultMessage: 'An error has occurred. Try refreshing the page, or check your internet connection.', description: 'Error message that appears when server responds with 500 error code', }, + 'server.ratelimit.error.message': { + id: 'server.ratelimit.error.message', + defaultMessage: 'An error has occurred because of too many requests. Please try again after some time.', + description: 'Error message that appears when server responds with 429 error code', + }, // enterprise sso strings 'enterprisetpa.title.heading': { id: 'enterprisetpa.title.heading', diff --git a/src/data/constants.js b/src/data/constants.js index 2ab5bf8c..19e63d56 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -12,6 +12,7 @@ export const SUPPORTED_ICON_CLASSES = ['apple', 'facebook', 'google', 'microsoft // Error Codes export const INTERNAL_SERVER_ERROR = 'internal-server-error'; +export const API_RATELIMIT_ERROR = 'api-ratelimit-error'; // States export const DEFAULT_STATE = 'default'; diff --git a/src/forgot-password/ForgotPasswordPage.jsx b/src/forgot-password/ForgotPasswordPage.jsx index dc68e107..3e5446dd 100644 --- a/src/forgot-password/ForgotPasswordPage.jsx +++ b/src/forgot-password/ForgotPasswordPage.jsx @@ -46,7 +46,7 @@ const ForgotPasswordPage = (props) => { ); } if (status === INTERNAL_SERVER_ERROR) { - return ; + return ; } return status === 'forbidden' ? : null; }; diff --git a/src/reset-password/ResetPasswordPage.jsx b/src/reset-password/ResetPasswordPage.jsx index 52f43fdc..1b46aeaa 100644 --- a/src/reset-password/ResetPasswordPage.jsx +++ b/src/reset-password/ResetPasswordPage.jsx @@ -20,7 +20,7 @@ import { APIFailureMessage, } from '../common-components'; import Spinner from './Spinner'; -import { INTERNAL_SERVER_ERROR } from '../data/constants'; +import { API_RATELIMIT_ERROR, INTERNAL_SERVER_ERROR } from '../data/constants'; const ResetPasswordPage = (props) => { const { intl } = props; @@ -34,7 +34,9 @@ const ResetPasswordPage = (props) => { const [bannerErrorMessage, setbannerErrorMessage] = useState(''); useEffect(() => { - if (props.status === 'failure' && props.errors !== INTERNAL_SERVER_ERROR) { + if (props.status === 'failure' + && props.errors !== INTERNAL_SERVER_ERROR + && props.errors !== API_RATELIMIT_ERROR) { setbannerErrorMessage(props.errors); setvalidationMessage(props.errors); setPasswordValidValue(false); @@ -105,7 +107,11 @@ const ResetPasswordPage = (props) => { } } else if (props.status === 'invalid' && props.errors === INTERNAL_SERVER_ERROR) { return ( - + + ); + } else if (props.status === 'invalid' && props.errors === API_RATELIMIT_ERROR) { + return ( + ); } else if (props.status === 'invalid') { return ; @@ -116,7 +122,10 @@ const ResetPasswordPage = (props) => { <> {props.status === 'failure' && props.errors === INTERNAL_SERVER_ERROR ? ( - + + ) : null} + {props.status === 'failure' && props.errors === API_RATELIMIT_ERROR ? ( + ) : null}
diff --git a/src/reset-password/data/sagas.js b/src/reset-password/data/sagas.js index 30f18077..1147ed73 100644 --- a/src/reset-password/data/sagas.js +++ b/src/reset-password/data/sagas.js @@ -14,7 +14,7 @@ import { } from './actions'; import { validateToken, resetPassword } from './service'; -import { INTERNAL_SERVER_ERROR } from '../../data/constants'; +import { INTERNAL_SERVER_ERROR, API_RATELIMIT_ERROR } from '../../data/constants'; // Services export function* handleValidateToken(action) { @@ -28,7 +28,13 @@ export function* handleValidateToken(action) { yield put(validateTokenFailure(isValid)); } } catch (err) { - yield put(validateTokenFailure(INTERNAL_SERVER_ERROR)); + const statusCodes = [429]; + if (err.response && statusCodes.includes(err.response.status)) { + yield put(validateTokenFailure(API_RATELIMIT_ERROR)); + } else { + yield put(validateTokenFailure(INTERNAL_SERVER_ERROR)); + } + logError(err); } } @@ -46,7 +52,12 @@ export function* handleResetPassword(action) { yield put(resetPasswordFailure(resetErrors)); } } catch (err) { - yield put(resetPasswordFailure(INTERNAL_SERVER_ERROR)); + const statusCodes = [429]; + if (err.response && statusCodes.includes(err.response.status)) { + yield put(resetPasswordFailure(API_RATELIMIT_ERROR)); + } else { + yield put(resetPasswordFailure(INTERNAL_SERVER_ERROR)); + } logError(err); } } diff --git a/src/reset-password/data/tests/sagas.test.js b/src/reset-password/data/tests/sagas.test.js index 843db8ee..d78fbcde 100644 --- a/src/reset-password/data/tests/sagas.test.js +++ b/src/reset-password/data/tests/sagas.test.js @@ -10,7 +10,7 @@ import { import { handleResetPassword, handleValidateToken } from '../sagas'; import * as api from '../service'; import initializeMockLogging from '../../../setupTest'; -import { INTERNAL_SERVER_ERROR } from '../../../data/constants'; +import { API_RATELIMIT_ERROR, INTERNAL_SERVER_ERROR } from '../../../data/constants'; const { loggingService } = initializeMockLogging(); @@ -51,9 +51,17 @@ describe('handleResetPassword', () => { resetPassword.mockClear(); }); - it('should call service and dispatch error action', async () => { + it('should call service and dispatch internal server error action', async () => { + const errorResponse = { + response: { + status: 500, + data: { + errorCode: INTERNAL_SERVER_ERROR, + }, + }, + }; const resetPassword = jest.spyOn(api, 'resetPassword') - .mockImplementation(() => Promise.reject()); + .mockImplementation(() => Promise.reject(errorResponse)); const dispatched = []; await runSaga( @@ -66,6 +74,30 @@ describe('handleResetPassword', () => { expect(dispatched).toEqual([resetPasswordBegin(), resetPasswordFailure(INTERNAL_SERVER_ERROR)]); resetPassword.mockClear(); }); + + it('should call service and dispatch ratelimit error', async () => { + const errorResponse = { + response: { + status: 429, + data: { + errorCode: API_RATELIMIT_ERROR, + }, + }, + }; + const resetPassword = jest.spyOn(api, 'resetPassword') + .mockImplementation(() => Promise.reject(errorResponse)); + + const dispatched = []; + await runSaga( + { dispatch: (action) => dispatched.push(action) }, + handleResetPassword, + params, + ); + + expect(resetPassword).toHaveBeenCalledTimes(1); + expect(dispatched).toEqual([resetPasswordBegin(), resetPasswordFailure(API_RATELIMIT_ERROR)]); + resetPassword.mockClear(); + }); }); describe('handleValidateToken', () => { @@ -80,9 +112,17 @@ describe('handleValidateToken', () => { loggingService.logError.mockReset(); }); - it('check server error on api failure', async () => { + it('check internal server error on api failure', async () => { + const errorResponse = { + response: { + status: 500, + data: { + errorCode: INTERNAL_SERVER_ERROR, + }, + }, + }; const validateToken = jest.spyOn(api, 'validateToken') - .mockImplementation(() => Promise.reject()); + .mockImplementation(() => Promise.reject(errorResponse)); const dispatched = []; await runSaga( @@ -93,5 +133,30 @@ describe('handleValidateToken', () => { expect(validateToken).toHaveBeenCalledTimes(1); expect(dispatched).toEqual([validateTokenBegin(), validateTokenFailure(INTERNAL_SERVER_ERROR)]); + validateToken.mockClear(); + }); + + it('should call service and dispatch ratelimit error', async () => { + const errorResponse = { + response: { + status: 429, + data: { + errorCode: API_RATELIMIT_ERROR, + }, + }, + }; + const validateToken = jest.spyOn(api, 'validateToken') + .mockImplementation(() => Promise.reject(errorResponse)); + + const dispatched = []; + await runSaga( + { dispatch: (action) => dispatched.push(action) }, + handleValidateToken, + params, + ); + + expect(validateToken).toHaveBeenCalledTimes(1); + expect(dispatched).toEqual([validateTokenBegin(), validateTokenFailure(API_RATELIMIT_ERROR)]); + validateToken.mockClear(); }); }); diff --git a/src/reset-password/messages.js b/src/reset-password/messages.js index 87c31541..b0579473 100644 --- a/src/reset-password/messages.js +++ b/src/reset-password/messages.js @@ -71,6 +71,11 @@ const messages = defineMessages({ defaultMessage: 'Token validation failure', description: 'Failed to validate reset password token error message.', }, + 'reset.server.ratelimit.error': { + id: 'reset.server.ratelimit.error', + defaultMessage: 'Too many requests.', + description: 'Too many request at server end point', + }, }); export default messages; diff --git a/src/reset-password/tests/ResetPasswordPage.test.jsx b/src/reset-password/tests/ResetPasswordPage.test.jsx index a701c34c..25427cb1 100644 --- a/src/reset-password/tests/ResetPasswordPage.test.jsx +++ b/src/reset-password/tests/ResetPasswordPage.test.jsx @@ -11,7 +11,7 @@ import { resetPassword } from '../data/actions'; import { APIFailureMessage } from '../../common-components'; import ResetPasswordPage from '../ResetPasswordPage'; -import { INTERNAL_SERVER_ERROR } from '../../data/constants'; +import { API_RATELIMIT_ERROR, INTERNAL_SERVER_ERROR } from '../../data/constants'; jest.mock('@edx/frontend-platform/auth'); @@ -268,4 +268,24 @@ describe('ResetPasswordPage', () => { const resetPasswordPage = mount(reduxWrapper()); expect(resetPasswordPage.find()).toBeTruthy(); }); + + it('check failure banner rendered on validate token api ratelimit', () => { + props = { + ...props, + status: 'invalid', + errors: API_RATELIMIT_ERROR, + }; + const resetPasswordPage = mount(reduxWrapper()); + expect(resetPasswordPage.find()).toBeTruthy(); + }); + + it('check failure banner rendered on reset password api ratelimit', () => { + props = { + ...props, + status: 'failure', + errors: API_RATELIMIT_ERROR, + }; + const resetPasswordPage = mount(reduxWrapper()); + expect(resetPasswordPage.find()).toBeTruthy(); + }); });