diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx
index 1fa09d7..b6d8f95 100644
--- a/src/components/app/App.jsx
+++ b/src/components/app/App.jsx
@@ -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);
@@ -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*/
@@ -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();
}
@@ -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 (
- {networkError && }
-
-
-
-
-
-
-
-
-
+ {startupError?.type === 'network' ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+ {throttlingError && (
+
+ )}
+
+ )}
setAlert(false)} severity={alert?.severity} duration={alert?.duration} />
@@ -129,4 +160,3 @@ const App = props => {
}
export default withRouter(App);
-
diff --git a/src/components/errors/ThrottlingError.jsx b/src/components/errors/ThrottlingError.jsx
new file mode 100644
index 0000000..3bc7eba
--- /dev/null
+++ b/src/components/errors/ThrottlingError.jsx
@@ -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 (
+
+
+
+ {t('errors.throttled.title')}
+
+
+ {getLimitMessage(t, limitType)}
+
+
+ {t('errors.throttled.retry_after')}
+
+
+ {formatTime(secondsLeft)}
+
+
+
+ )
+}
+
+export default ThrottlingError;
diff --git a/src/components/map-projects/MapProjects.jsx b/src/components/map-projects/MapProjects.jsx
index f511823..cc20e2f 100644
--- a/src/components/map-projects/MapProjects.jsx
+++ b/src/components/map-projects/MapProjects.jsx
@@ -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])
}
diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json
index a05bc05..ec1cce3 100644
--- a/src/i18n/locales/en/translations.json
+++ b/src/i18n/locales/en/translations.json
@@ -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",
diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json
index b0029b9..c5bedc6 100644
--- a/src/i18n/locales/es/translations.json
+++ b/src/i18n/locales/es/translations.json
@@ -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",
diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json
index b9dcf20..9f3ddda 100644
--- a/src/i18n/locales/zh/translations.json
+++ b/src/i18n/locales/zh/translations.json
@@ -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": "仪表盘",
diff --git a/src/services/APIService.js b/src/services/APIService.js
index d80d66a..4dd96c1 100644
--- a/src/services/APIService.js
+++ b/src/services/APIService.js
@@ -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: [] },
@@ -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();
@@ -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;
});
}
@@ -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) {
@@ -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;