Skip to content

Commit 032e492

Browse files
authored
Merge pull request #12 from OpenConceptLab/issues#2483
OpenConceptLab/ocl_issues#2483 | Throttling Error UI
2 parents 3b20ced + 6e37616 commit 032e492

7 files changed

Lines changed: 232 additions & 26 deletions

File tree

src/components/app/App.jsx

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Error403 from '../errors/Error403';
1010
import Error401 from '../errors/Error401';
1111
import WaitListing from '../errors/WaitListing'
1212
import NetworkError from '../errors/NetworkError'
13+
import ThrottlingError from '../errors/ThrottlingError'
1314
import ErrorBoundary from '../errors/ErrorBoundary';
1415
import CheckAuth from './CheckAuth'
1516
import Footer from './Footer';
@@ -23,6 +24,7 @@ import { OperationsContext } from './LayoutContext';
2324
import Alert from '../common/Alert';
2425
import MapProject from '../map-projects/MapProject'
2526
import MapProjects from '../map-projects/MapProjects'
27+
import { useTranslation } from 'react-i18next';
2628

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

4951
const App = props => {
50-
const [networkError, setNetworkError] = React.useState(false)
52+
const { t } = useTranslation();
53+
const [startupError, setStartupError] = React.useState(null)
54+
const [throttlingError, setThrottlingError] = React.useState(null)
5155
const { alert, setAlert, setToggles } = React.useContext(OperationsContext);
5256
const setupHotJar = () => {
5357
/*eslint no-undef: 0*/
@@ -60,8 +64,8 @@ const App = props => {
6064
return new Promise(resolve => {
6165
APIService.toggles().get().then(response => {
6266
if(response === 'Network Error')
63-
setNetworkError(true)
64-
else {
67+
setStartupError({ type: 'network' })
68+
else if(response?.status !== 429) {
6569
setToggles(response.data)
6670
resolve();
6771
}
@@ -96,29 +100,56 @@ const App = props => {
96100
}
97101

98102
React.useEffect(() => {
103+
const unsubscribe = APIService.onThrottle(details => {
104+
setThrottlingError(details)
105+
})
106+
99107
forceLoginUser()
100108
fetchToggles()
101109
addLogoutListenerForAllTabs()
102110
recordGAPageView()
103111
setupHotJar()
112+
113+
return () => unsubscribe()
104114
}, [])
105115

116+
const clearThrottlingError = React.useCallback(() => {
117+
setThrottlingError(null)
118+
setAlert({
119+
severity: 'success',
120+
message: t('errors.throttled.ended'),
121+
duration: 4000,
122+
})
123+
}, [setAlert, t])
124+
106125
return (
107126
<div>
108127
<DocumentTitle/>
109128
<Header>
110129
<ErrorBoundary>
111130
<main className='content'>
112-
{networkError && <NetworkError />}
113-
<Switch>
114-
<Route exact path="/oidc/login" component={OIDLoginCallback} />
115-
<AuthenticationRequiredRoute exact path='/' component={MapProjects} />
116-
<AuthenticationRequiredRoute exact path='/map-projects' component={MapProjects} />
117-
<AuthenticationRequiredRoute exact path='/map-projects/new' component={MapProject} />
118-
<AuthenticationRequiredRoute exact path='/:ownerType/:owner/map-projects/:projectId' component={MapProject} />
119-
<Route exact path='/403' component={Error403} />
120-
<Route component={Error404} />
121-
</Switch>
131+
{startupError?.type === 'network' ? (
132+
<NetworkError />
133+
) : (
134+
<React.Fragment>
135+
<Switch>
136+
<Route exact path="/oidc/login" component={OIDLoginCallback} />
137+
<AuthenticationRequiredRoute exact path='/' component={MapProjects} />
138+
<AuthenticationRequiredRoute exact path='/map-projects' component={MapProjects} />
139+
<AuthenticationRequiredRoute exact path='/map-projects/new' component={MapProject} />
140+
<AuthenticationRequiredRoute exact path='/:ownerType/:owner/map-projects/:projectId' component={MapProject} />
141+
<Route exact path='/403' component={Error403} />
142+
<Route component={Error404} />
143+
</Switch>
144+
{throttlingError && (
145+
<ThrottlingError
146+
retryAfter={throttlingError.retryAfter}
147+
limitType={throttlingError.limitType}
148+
onExpire={clearThrottlingError}
149+
/>
150+
)}
151+
</React.Fragment>
152+
)}
122153
<Alert message={alert?.message} onClose={() => setAlert(false)} severity={alert?.severity} duration={alert?.duration} />
123154
</main>
124155
</ErrorBoundary>
@@ -129,4 +160,3 @@ const App = props => {
129160
}
130161

131162
export default withRouter(App);
132-
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React from 'react';
2+
import { useTranslation } from 'react-i18next'
3+
4+
const formatTime = totalSeconds => {
5+
const safeSeconds = Math.max(totalSeconds, 0);
6+
const minutes = Math.floor(safeSeconds / 60);
7+
const seconds = safeSeconds % 60;
8+
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
9+
}
10+
11+
const getLimitMessage = (t, limitType) => {
12+
if(limitType === 'minute')
13+
return t('errors.throttled.limit_hit.minute');
14+
if(limitType === 'day')
15+
return t('errors.throttled.limit_hit.day');
16+
if(limitType === 'minute_and_day')
17+
return t('errors.throttled.limit_hit.minute_and_day');
18+
19+
return t('errors.throttled.limit_hit.unknown');
20+
}
21+
22+
const ThrottlingError = ({
23+
retryAfter = 0,
24+
limitType = 'unknown',
25+
onExpire,
26+
}) => {
27+
const { t } = useTranslation()
28+
const [secondsLeft, setSecondsLeft] = React.useState(Math.max(retryAfter, 0))
29+
30+
React.useEffect(() => {
31+
setSecondsLeft(Math.max(retryAfter, 0))
32+
}, [retryAfter])
33+
34+
React.useEffect(() => {
35+
if(retryAfter <= 0 && onExpire)
36+
onExpire()
37+
}, [retryAfter, onExpire])
38+
39+
React.useEffect(() => {
40+
if(secondsLeft <= 0)
41+
return undefined;
42+
43+
const timer = window.setInterval(() => {
44+
setSecondsLeft(currentSeconds => {
45+
if(currentSeconds <= 1) {
46+
window.clearInterval(timer)
47+
if(onExpire)
48+
onExpire()
49+
return 0;
50+
}
51+
52+
return currentSeconds - 1;
53+
})
54+
}, 1000)
55+
56+
return () => window.clearInterval(timer)
57+
}, [secondsLeft])
58+
59+
return (
60+
<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'}}>
61+
<div className='col-xs-12' style={{maxWidth: '560px'}}>
62+
<p style={{color: '#000', fontSize: '24px', margin: '16px 0'}}>
63+
{t('errors.throttled.title')}
64+
</p>
65+
<p style={{color: '#333', fontSize: '16px', margin: '0 0 12px'}}>
66+
{getLimitMessage(t, limitType)}
67+
</p>
68+
<p style={{color: '#333', fontSize: '16px', margin: '0 0 12px'}}>
69+
{t('errors.throttled.retry_after')}
70+
</p>
71+
<p style={{color: '#000', fontSize: '36px', fontWeight: 700, margin: '0'}}>
72+
{formatTime(secondsLeft)}
73+
</p>
74+
</div>
75+
</div>
76+
)
77+
}
78+
79+
export default ThrottlingError;

src/components/map-projects/MapProjects.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ const MapProjects = () => {
3737
const fetchOrgProjects = () => APIService.users(user.username).appendToUrl('orgs/map-projects/').get().then(handleProjectsResponse)
3838

3939
const handleProjectsResponse = response => {
40-
setProjects(prev => [...prev, ...(response?.data || [])])
40+
const nextProjects = Array.isArray(response?.data) ? response.data : [];
41+
setProjects(prev => [...prev, ...nextProjects])
4142
setLoading(prev => [...prev, false])
4243
}
4344

src/i18n/locales/en/translations.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,18 @@
108108
"401": "Login is required",
109109
"403": "Access to this page is not authorized",
110110
"404": "Sorry your page could not be found.",
111-
"network_error": "Network Error"
111+
"network_error": "Network Error",
112+
"throttled": {
113+
"title": "Too many requests",
114+
"retry_after": "This will disappear after:",
115+
"ended": "Rate limit window ended. You can continue where you left off.",
116+
"limit_hit": {
117+
"minute": "The per-minute request limit has been reached.",
118+
"day": "The daily request limit has been reached.",
119+
"minute_and_day": "The per-minute and daily request limits have been reached",
120+
"unknown": "A request limit has been reached."
121+
}
122+
}
112123
},
113124
"dashboard": {
114125
"name": "Dashboard",

src/i18n/locales/es/translations.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,18 @@
109109
"401": "Se requiere iniciar sesión",
110110
"403": "No tiene autorización para acceder a esta página",
111111
"404": "Lo siento, no se pudo encontrar tu página.",
112-
"network_error": "Error de red"
112+
"network_error": "Error de red",
113+
"throttled": {
114+
"title": "Demasiadas solicitudes",
115+
"retry_after": "Esto desaparecerá después de:",
116+
"ended": "La ventana del límite de solicitudes terminó. Puedes continuar donde lo dejaste.",
117+
"limit_hit": {
118+
"minute": "Se alcanzó el límite de solicitudes por minuto.",
119+
"day": "Se alcanzó el límite diario de solicitudes.",
120+
"minute_and_day": "Se alcanzaron los límites de solicitudes por minuto y por día.",
121+
"unknown": "Se alcanzó un límite de solicitudes."
122+
}
123+
}
113124
},
114125
"dashboard": {
115126
"my": "Mi tablero",

src/i18n/locales/zh/translations.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,18 @@
108108
"401": "需要登录",
109109
"403": "尚未获得访问该页面的权限",
110110
"404": "很抱歉,未能找到您的页面。",
111-
"network_error": "网络错误"
111+
"network_error": "网络错误",
112+
"throttled": {
113+
"title": "请求过多",
114+
"retry_after": "此提示将在以下时间后消失:",
115+
"ended": "请求限制窗口已结束。您可以从刚才的位置继续。",
116+
"limit_hit": {
117+
"minute": "已达到每分钟请求限制。",
118+
"day": "已达到每日请求限制。",
119+
"minute_and_day": "已达到每分钟和每日请求限制。",
120+
"unknown": "已达到请求限制。"
121+
}
122+
}
112123
},
113124
"dashboard": {
114125
"name": "仪表盘",

src/services/APIService.js

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {get, omit, isPlainObject, isString, defaults } from 'lodash';
44
import { currentUserToken, getAPIURL, logoutUser } from '../common/utils';
55

66
const APIServiceProvider = {};
7+
const throttlingListeners = new Set();
78
const RESOURCES = [
89
{ name: 'concepts', relations: [] },
910
{ name: 'mappings', relations: [] },
@@ -19,6 +20,60 @@ const RESOURCES = [
1920
{ name: 'new', relations: [] },
2021
];
2122

23+
const getHeaderValue = (headers = {}, key) => {
24+
if(headers && typeof headers.get === 'function') {
25+
const headerValue = headers.get(key) ?? headers.get(key.toLowerCase()) ?? headers.get(key.toUpperCase());
26+
if(headerValue !== undefined && headerValue !== null)
27+
return headerValue;
28+
}
29+
30+
return headers?.[key] ?? headers?.[key.toLowerCase()] ?? headers?.[key.toUpperCase()];
31+
};
32+
33+
export const getThrottlingDetails = response => {
34+
const retryAfter = Number(getHeaderValue(response?.headers, 'retry-after'));
35+
const minuteRemaining = Number(getHeaderValue(response?.headers, 'x-limitremaining-minute'));
36+
const dayRemaining = Number(getHeaderValue(response?.headers, 'x-limitremaining-day'));
37+
const hasMinuteRemaining = Number.isFinite(minuteRemaining);
38+
const hasDayRemaining = Number.isFinite(dayRemaining);
39+
let limitType = 'unknown';
40+
if(hasMinuteRemaining && minuteRemaining <= 0 && hasDayRemaining && dayRemaining <= 0)
41+
limitType = 'minute_and_day';
42+
else if(hasMinuteRemaining && minuteRemaining <= 0)
43+
limitType = 'minute';
44+
else if(hasDayRemaining && dayRemaining <= 0)
45+
limitType = 'day';
46+
47+
return {
48+
retryAfter: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 0,
49+
limitType,
50+
minuteRemaining: hasMinuteRemaining ? minuteRemaining : null,
51+
dayRemaining: hasDayRemaining ? dayRemaining : null,
52+
response,
53+
};
54+
}
55+
56+
const notifyThrottlingListeners = response => {
57+
const details = getThrottlingDetails(response);
58+
throttlingListeners.forEach(listener => listener(details));
59+
}
60+
61+
const createPendingRequest = () => new Promise(() => {})
62+
63+
const handleAPIError = (error, raw=false) => {
64+
if(error?.response?.status === 401 && error?.response?.config?.url?.startsWith(getAPIURL())) {
65+
logoutUser(true)
66+
return { handled: true, result: undefined };
67+
}
68+
69+
if(error?.response?.status === 429) {
70+
notifyThrottlingListeners(error.response);
71+
return { handled: true, result: raw ? error : createPendingRequest() };
72+
}
73+
74+
return { handled: false };
75+
}
76+
2277
class APIService {
2378
constructor(name, id, relations) {
2479
const apiURL = getAPIURL();
@@ -77,14 +132,14 @@ class APIService {
77132
return axios(request)
78133
.then(response => response || null)
79134
.catch(error => {
80-
if(error?.response?.status === 401 && error?.response?.config?.url?.startsWith(getAPIURL())) {
81-
logoutUser(true)
82-
} else {
83-
if(raw)
84-
return error;
135+
const { handled, result } = handleAPIError(error, raw);
136+
if(handled)
137+
return result;
85138

86-
return error.response ? error.response.data : error.message;
87-
}
139+
if(raw)
140+
return error;
141+
142+
return error.response ? error.response.data : error.message;
88143

89144
});
90145
}
@@ -108,7 +163,10 @@ class APIService {
108163
}
109164
let request = this.getRequest(method, data, token, headers, query);
110165
request = {...request, ...omit(config, ['headers', 'query'])};
111-
return axios(request);
166+
return axios(request).catch(error => {
167+
handleAPIError(error);
168+
return Promise.reject(error);
169+
});
112170
};
113171

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

205+
APIServiceProvider.onThrottle = listener => {
206+
throttlingListeners.add(listener);
207+
return () => throttlingListeners.delete(listener);
208+
};
209+
147210
export default APIServiceProvider;

0 commit comments

Comments
 (0)