From a500a0599e3effd988a3585056ca24500bd571f7 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Tue, 21 Apr 2026 12:33:42 +0530 Subject: [PATCH] OpenConceptLab/ocl_issues#2483 | Throttling Error UI --- src/components/app/App.jsx | 60 ++++++++++++---- src/components/errors/ThrottlingError.jsx | 79 +++++++++++++++++++++ src/components/map-projects/MapProjects.jsx | 3 +- src/i18n/locales/en/translations.json | 13 +++- src/i18n/locales/es/translations.json | 13 +++- src/i18n/locales/zh/translations.json | 13 +++- src/services/APIService.js | 79 ++++++++++++++++++--- 7 files changed, 233 insertions(+), 27 deletions(-) create mode 100644 src/components/errors/ThrottlingError.jsx 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;