Ratelimit handling for validate and password reset endpoints.

* This patch implements ratelimit handling code for
validate token and password reset end points.
* Adds graceful error messages for such case

VAN-312
This commit is contained in:
Adeel Khan
2021-02-23 09:13:45 +05:00
parent a8714b8927
commit 117325ec2a
9 changed files with 155 additions and 18 deletions

View File

@@ -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 (
<div className="d-flex justify-content-center">
<div className="d-flex justify-content-center m-4">
<div className="d-flex flex-column mw-500">
<Alert id="internal-server-error" variant="danger">
<Alert id={id} variant="danger">
<Alert.Heading>
{header}
</Alert.Heading>
{intl.formatMessage(messages['internal.server.error.message'])}
<ul>
<li key={errorMessage}>
{errorMessage}
</li>
</ul>
</Alert>
</div>
</div>
@@ -24,6 +44,7 @@ const APIFailureMessage = (props) => {
APIFailureMessage.propTypes = {
intl: intlShape.isRequired,
header: PropTypes.string.isRequired,
errorCode: PropTypes.string.isRequired,
};
export default injectIntl(APIFailureMessage);

View File

@@ -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',

View File

@@ -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';

View File

@@ -46,7 +46,7 @@ const ForgotPasswordPage = (props) => {
);
}
if (status === INTERNAL_SERVER_ERROR) {
return <APIFailureMessage header={header} />;
return <APIFailureMessage header={header} errorCode={INTERNAL_SERVER_ERROR} />;
}
return status === 'forbidden' ? <RequestInProgressAlert /> : null;
};

View File

@@ -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 (
<APIFailureMessage header={intl.formatMessage(messages['reset.password.token.validation.sever.error'])} />
<APIFailureMessage header={intl.formatMessage(messages['reset.password.token.validation.sever.error'])} errorCode={INTERNAL_SERVER_ERROR} />
);
} else if (props.status === 'invalid' && props.errors === API_RATELIMIT_ERROR) {
return (
<APIFailureMessage header={intl.formatMessage(messages['reset.server.ratelimit.error'])} errorCode={API_RATELIMIT_ERROR} />
);
} else if (props.status === 'invalid') {
return <InvalidTokenMessage />;
@@ -116,7 +122,10 @@ const ResetPasswordPage = (props) => {
<>
{props.status === 'failure' && props.errors === INTERNAL_SERVER_ERROR ? (
<APIFailureMessage header={intl.formatMessage(messages['reset.password.request.server.error'])} />
<APIFailureMessage header={intl.formatMessage(messages['reset.password.request.server.error'])} errorCode={INTERNAL_SERVER_ERROR} />
) : null}
{props.status === 'failure' && props.errors === API_RATELIMIT_ERROR ? (
<APIFailureMessage header={intl.formatMessage(messages['reset.server.ratelimit.error'])} errorCode={API_RATELIMIT_ERROR} />
) : null}
<div id="main" className="d-flex justify-content-center m-4">
<div className="d-flex flex-column mw-500">

View File

@@ -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);
}
}

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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(<IntlResetPasswordPage {...props} />));
expect(resetPasswordPage.find(<APIFailureMessage />)).toBeTruthy();
});
it('check failure banner rendered on validate token api ratelimit', () => {
props = {
...props,
status: 'invalid',
errors: API_RATELIMIT_ERROR,
};
const resetPasswordPage = mount(reduxWrapper(<IntlResetPasswordPage {...props} />));
expect(resetPasswordPage.find(<APIFailureMessage />)).toBeTruthy();
});
it('check failure banner rendered on reset password api ratelimit', () => {
props = {
...props,
status: 'failure',
errors: API_RATELIMIT_ERROR,
};
const resetPasswordPage = mount(reduxWrapper(<IntlResetPasswordPage {...props} />));
expect(resetPasswordPage.find(<APIFailureMessage />)).toBeTruthy();
});
});