Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 45 additions & 15 deletions src/components/app/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,21 @@ import Error403 from '../errors/Error403';
import Error401 from '../errors/Error401';
import WaitListing from '../errors/WaitListing'
import NetworkError from '../errors/NetworkError'
import ThrottlingError from '../errors/ThrottlingError'
import ErrorBoundary from '../errors/ErrorBoundary';
import CheckAuth from './CheckAuth'
import Footer from './Footer';
import DocumentTitle from "./DocumentTitle"
import './App.scss';
import { hotjar } from 'react-hotjar';
import APIService from '../../services/APIService'
import APIService, { getThrottlingDetails } from '../../services/APIService'
import Header from './Header';
import OIDLoginCallback from '../users/OIDLoginCallback';
import { OperationsContext } from './LayoutContext';
import Alert from '../common/Alert';
import MapProject from '../map-projects/MapProject'
import MapProjects from '../map-projects/MapProjects'
import { useTranslation } from 'react-i18next';

const AuthenticationRequiredRoute = ({component: Component, ...rest}) => {
const { toggles } = React.useContext(OperationsContext);
Expand All @@ -47,7 +49,9 @@ const AuthenticationRequiredRoute = ({component: Component, ...rest}) => {
}

const App = props => {
const [networkError, setNetworkError] = React.useState(false)
const { t } = useTranslation();
const [startupError, setStartupError] = React.useState(null)
const [throttlingError, setThrottlingError] = React.useState(null)
const { alert, setAlert, setToggles } = React.useContext(OperationsContext);
const setupHotJar = () => {
/*eslint no-undef: 0*/
Expand All @@ -60,8 +64,8 @@ const App = props => {
return new Promise(resolve => {
APIService.toggles().get().then(response => {
if(response === 'Network Error')
setNetworkError(true)
else {
setStartupError({ type: 'network' })
else if(response?.status !== 429) {
setToggles(response.data)
resolve();
}
Expand Down Expand Up @@ -96,29 +100,56 @@ const App = props => {
}

React.useEffect(() => {
const unsubscribe = APIService.onThrottle(details => {
setThrottlingError(getThrottlingDetails(details.response || details))
})

forceLoginUser()
fetchToggles()
addLogoutListenerForAllTabs()
recordGAPageView()
setupHotJar()

return () => unsubscribe()
}, [])

const clearThrottlingError = React.useCallback(() => {
setThrottlingError(null)
setAlert({
severity: 'success',
message: t('errors.throttled.ended'),
duration: 4000,
})
}, [setAlert, t])

return (
<div>
<DocumentTitle/>
<Header>
<ErrorBoundary>
<main className='content'>
{networkError && <NetworkError />}
<Switch>
<Route exact path="/oidc/login" component={OIDLoginCallback} />
<AuthenticationRequiredRoute exact path='/' component={MapProjects} />
<AuthenticationRequiredRoute exact path='/map-projects' component={MapProjects} />
<AuthenticationRequiredRoute exact path='/map-projects/new' component={MapProject} />
<AuthenticationRequiredRoute exact path='/:ownerType/:owner/map-projects/:projectId' component={MapProject} />
<Route exact path='/403' component={Error403} />
<Route component={Error404} />
</Switch>
{startupError?.type === 'network' ? (
<NetworkError />
) : (
<React.Fragment>
<Switch>
<Route exact path="/oidc/login" component={OIDLoginCallback} />
<AuthenticationRequiredRoute exact path='/' component={MapProjects} />
<AuthenticationRequiredRoute exact path='/map-projects' component={MapProjects} />
<AuthenticationRequiredRoute exact path='/map-projects/new' component={MapProject} />
<AuthenticationRequiredRoute exact path='/:ownerType/:owner/map-projects/:projectId' component={MapProject} />
<Route exact path='/403' component={Error403} />
<Route component={Error404} />
</Switch>
{throttlingError && (
<ThrottlingError
retryAfter={throttlingError.retryAfter}
limitType={throttlingError.limitType}
onExpire={clearThrottlingError}
/>
)}
</React.Fragment>
)}
<Alert message={alert?.message} onClose={() => setAlert(false)} severity={alert?.severity} duration={alert?.duration} />
</main>
</ErrorBoundary>
Expand All @@ -129,4 +160,3 @@ const App = props => {
}

export default withRouter(App);

79 changes: 79 additions & 0 deletions src/components/errors/ThrottlingError.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React from 'react';
import { useTranslation } from 'react-i18next'

const formatTime = totalSeconds => {
const safeSeconds = Math.max(totalSeconds, 0);
const minutes = Math.floor(safeSeconds / 60);
const seconds = safeSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}

const getLimitMessage = (t, limitType) => {
if(limitType === 'minute')
return t('errors.throttled.limit_hit.minute');
if(limitType === 'day')
return t('errors.throttled.limit_hit.day');
if(limitType === 'minute_and_day')
return t('errors.throttled.limit_hit.minute_and_day');

return t('errors.throttled.limit_hit.unknown');
}

const ThrottlingError = ({
retryAfter = 0,
limitType = 'unknown',
onExpire,
}) => {
const { t } = useTranslation()
const [secondsLeft, setSecondsLeft] = React.useState(Math.max(retryAfter, 0))

React.useEffect(() => {
setSecondsLeft(Math.max(retryAfter, 0))
}, [retryAfter])

React.useEffect(() => {
if(retryAfter <= 0 && onExpire)
onExpire()
}, [retryAfter, onExpire])

React.useEffect(() => {
if(secondsLeft <= 0)
return undefined;

const timer = window.setInterval(() => {
setSecondsLeft(currentSeconds => {
if(currentSeconds <= 1) {
window.clearInterval(timer)
if(onExpire)
onExpire()
return 0;
}

return currentSeconds - 1;
})
}, 1000)

return () => window.clearInterval(timer)
}, [secondsLeft])

return (
<div style={{position: 'fixed', inset: 0, zIndex: 1400, background: 'rgba(255, 255, 255, 0.6)', backdropFilter: 'blur(2px)', display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', flexDirection: 'column', padding: '24px'}}>
<div className='col-xs-12' style={{maxWidth: '560px'}}>
<p style={{color: '#000', fontSize: '24px', margin: '16px 0'}}>
{t('errors.throttled.title')}
</p>
<p style={{color: '#333', fontSize: '16px', margin: '0 0 12px'}}>
{getLimitMessage(t, limitType)}
</p>
<p style={{color: '#333', fontSize: '16px', margin: '0 0 12px'}}>
{t('errors.throttled.retry_after')}
</p>
<p style={{color: '#000', fontSize: '36px', fontWeight: 700, margin: '0'}}>
{formatTime(secondsLeft)}
</p>
</div>
</div>
)
}

export default ThrottlingError;
3 changes: 2 additions & 1 deletion src/components/map-projects/MapProjects.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ const MapProjects = () => {
const fetchOrgProjects = () => APIService.users(user.username).appendToUrl('orgs/map-projects/').get().then(handleProjectsResponse)

const handleProjectsResponse = response => {
setProjects(prev => [...prev, ...(response?.data || [])])
const nextProjects = Array.isArray(response?.data) ? response.data : [];
setProjects(prev => [...prev, ...nextProjects])
setLoading(prev => [...prev, false])
}

Expand Down
13 changes: 12 additions & 1 deletion src/i18n/locales/en/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,18 @@
"401": "Login is required",
"403": "Access to this page is not authorized",
"404": "Sorry your page could not be found.",
"network_error": "Network Error"
"network_error": "Network Error",
"throttled": {
"title": "Too many requests",
"retry_after": "This will disappear after:",
"ended": "Rate limit window ended. You can continue where you left off.",
"limit_hit": {
"minute": "The per-minute request limit has been reached.",
"day": "The daily request limit has been reached.",
"minute_and_day": "The per-minute and daily request limits have been reached",
"unknown": "A request limit has been reached."
}
}
},
"dashboard": {
"name": "Dashboard",
Expand Down
13 changes: 12 additions & 1 deletion src/i18n/locales/es/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,18 @@
"401": "Se requiere iniciar sesión",
"403": "No tiene autorización para acceder a esta página",
"404": "Lo siento, no se pudo encontrar tu página.",
"network_error": "Error de red"
"network_error": "Error de red",
"throttled": {
"title": "Demasiadas solicitudes",
"retry_after": "Esto desaparecerá después de:",
"ended": "La ventana del límite de solicitudes terminó. Puedes continuar donde lo dejaste.",
"limit_hit": {
"minute": "Se alcanzó el límite de solicitudes por minuto. Restante por minuto: {{minuteRemaining}}.",
"day": "Se alcanzó el límite diario de solicitudes. Restante del día: {{dayRemaining}}.",
"minute_and_day": "Se alcanzaron los límites de solicitudes por minuto y por día. Restante por minuto: {{minuteRemaining}}. Restante del día: {{dayRemaining}}.",
"unknown": "Se alcanzó un límite de solicitudes."
}
}
},
"dashboard": {
"my": "Mi tablero",
Expand Down
13 changes: 12 additions & 1 deletion src/i18n/locales/zh/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,18 @@
"401": "需要登录",
"403": "尚未获得访问该页面的权限",
"404": "很抱歉,未能找到您的页面。",
"network_error": "网络错误"
"network_error": "网络错误",
"throttled": {
"title": "请求过多",
"retry_after": "此提示将在以下时间后消失:",
"ended": "请求限制窗口已结束。您可以从刚才的位置继续。",
"limit_hit": {
"minute": "已达到每分钟请求限制。当前分钟剩余:{{minuteRemaining}}。",
"day": "已达到每日请求限制。今日剩余:{{dayRemaining}}。",
"minute_and_day": "已达到每分钟和每日请求限制。当前分钟剩余:{{minuteRemaining}}。今日剩余:{{dayRemaining}}。",
"unknown": "已达到请求限制。"
}
}
},
"dashboard": {
"name": "仪表盘",
Expand Down
79 changes: 71 additions & 8 deletions src/services/APIService.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {get, omit, isPlainObject, isString, defaults } from 'lodash';
import { currentUserToken, getAPIURL, logoutUser } from '../common/utils';

const APIServiceProvider = {};
const throttlingListeners = new Set();
const RESOURCES = [
{ name: 'concepts', relations: [] },
{ name: 'mappings', relations: [] },
Expand All @@ -19,6 +20,60 @@ const RESOURCES = [
{ name: 'new', relations: [] },
];

const getHeaderValue = (headers = {}, key) => {
if(headers && typeof headers.get === 'function') {
const headerValue = headers.get(key) ?? headers.get(key.toLowerCase()) ?? headers.get(key.toUpperCase());
if(headerValue !== undefined && headerValue !== null)
return headerValue;
}

return headers?.[key] ?? headers?.[key.toLowerCase()] ?? headers?.[key.toUpperCase()];
};

export const getThrottlingDetails = response => {
const retryAfter = Number(getHeaderValue(response?.headers, 'retry-after'));
const minuteRemaining = Number(getHeaderValue(response?.headers, 'x-limitremaining-minute'));
const dayRemaining = Number(getHeaderValue(response?.headers, 'x-limitremaining-day'));
const hasMinuteRemaining = Number.isFinite(minuteRemaining);
const hasDayRemaining = Number.isFinite(dayRemaining);
let limitType = 'unknown';
if(hasMinuteRemaining && minuteRemaining <= 0 && hasDayRemaining && dayRemaining <= 0)
limitType = 'minute_and_day';
else if(hasMinuteRemaining && minuteRemaining <= 0)
limitType = 'minute';
else if(hasDayRemaining && dayRemaining <= 0)
limitType = 'day';

return {
retryAfter: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 0,
limitType,
minuteRemaining: hasMinuteRemaining ? minuteRemaining : null,
dayRemaining: hasDayRemaining ? dayRemaining : null,
response,
};
}

const notifyThrottlingListeners = response => {
const details = getThrottlingDetails(response);
throttlingListeners.forEach(listener => listener(details));
}

const createPendingRequest = () => new Promise(() => {})

const handleAPIError = (error, raw=false) => {
if(error?.response?.status === 401 && error?.response?.config?.url?.startsWith(getAPIURL())) {
logoutUser(true)
return { handled: true, result: undefined };
}

if(error?.response?.status === 429) {
notifyThrottlingListeners(error.response);
return { handled: true, result: raw ? error : createPendingRequest() };
}

return { handled: false };
}

class APIService {
constructor(name, id, relations) {
const apiURL = getAPIURL();
Expand Down Expand Up @@ -77,14 +132,14 @@ class APIService {
return axios(request)
.then(response => response || null)
.catch(error => {
if(error?.response?.status === 401 && error?.response?.config?.url?.startsWith(getAPIURL())) {
logoutUser(true)
} else {
if(raw)
return error;
const { handled, result } = handleAPIError(error, raw);
if(handled)
return result;

return error.response ? error.response.data : error.message;
}
if(raw)
return error;

return error.response ? error.response.data : error.message;

});
}
Expand All @@ -108,7 +163,10 @@ class APIService {
}
let request = this.getRequest(method, data, token, headers, query);
request = {...request, ...omit(config, ['headers', 'query'])};
return axios(request);
return axios(request).catch(error => {
handleAPIError(error);
return Promise.reject(error);
});
};

getHeaders(token, headers) {
Expand Down Expand Up @@ -144,4 +202,9 @@ RESOURCES.forEach(resource => {
APIServiceProvider[resource.name] = (id, query) => new APIService(resource.name, id, resource.relations, query);
});

APIServiceProvider.onThrottle = listener => {
throttlingListeners.add(listener);
return () => throttlingListeners.delete(listener);
};

export default APIServiceProvider;