Skip to content

Commit 421c3e4

Browse files
authored
Merge pull request #1736 from plotly/api-retrying
Api retrying
2 parents cf75745 + bf45c98 commit 421c3e4

File tree

11 files changed

+293
-59
lines changed

11 files changed

+293
-59
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
99
### Added
1010
- [#1702](https://github.com/plotly/dash/pull/1702) Added a new `@app.long_callback` decorator to support callback functions that take a long time to run. See the PR and documentation for more information.
1111
- [#1514](https://github.com/plotly/dash/pull/1514) Perform json encoding using the active plotly JSON engine. This will default to the faster orjson encoder if the `orjson` package is installed.
12+
- [#1736](https://github.com/plotly/dash/pull/1736) Add support for `request_refresh_jwt` hook and retry requests that used expired JWT tokens.
1213

1314
### Changed
1415
- [#1679](https://github.com/plotly/dash/pull/1679) Restructure `dash`, `dash-core-components`, `dash-html-components`, and `dash-table` into a singular monorepo and move component packages into `dash`. This change makes the component modules available for import within the `dash` namespace, and simplifies the import pattern for a Dash app. From a development standpoint, all future changes to component modules will be made within the `components` directory, and relevant packages updated with the `dash-update-components` CLI command.

dash/dash-renderer/src/AppContainer.react.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,29 @@ import Loading from './components/core/Loading.react';
66
import Toolbar from './components/core/Toolbar.react';
77
import Reloader from './components/core/Reloader.react';
88
import {setHooks, setConfig} from './actions/index';
9-
import {type} from 'ramda';
9+
import {type, memoizeWith, identity} from 'ramda';
1010

1111
class UnconnectedAppContainer extends React.Component {
1212
constructor(props) {
1313
super(props);
1414
if (
1515
props.hooks.request_pre !== null ||
16-
props.hooks.request_post !== null
16+
props.hooks.request_post !== null ||
17+
props.hooks.request_refresh_jwt !== null
1718
) {
18-
props.dispatch(setHooks(props.hooks));
19+
let hooks = props.hooks;
20+
21+
if (hooks.request_refresh_jwt) {
22+
hooks = {
23+
...hooks,
24+
request_refresh_jwt: memoizeWith(
25+
identity,
26+
hooks.request_refresh_jwt
27+
)
28+
};
29+
}
30+
31+
props.dispatch(setHooks(hooks));
1932
}
2033
}
2134

dash/dash-renderer/src/AppProvider.react.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@ const AppProvider = ({hooks}: any) => {
1818
AppProvider.propTypes = {
1919
hooks: PropTypes.shape({
2020
request_pre: PropTypes.func,
21-
request_post: PropTypes.func
21+
request_post: PropTypes.func,
22+
request_refresh_jwt: PropTypes.func
2223
})
2324
};
2425

2526
AppProvider.defaultProps = {
2627
hooks: {
2728
request_pre: null,
28-
request_post: null
29+
request_post: null,
30+
request_refresh_jwt: null
2931
}
3032
};
3133

dash/dash-renderer/src/actions/api.js

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {mergeDeepRight, once} from 'ramda';
2-
import {handleAsyncError, getCSRFHeader} from '../actions';
2+
import {getCSRFHeader, handleAsyncError, addHttpHeaders} from '../actions';
33
import {urlBase} from './utils';
4+
import {MAX_AUTH_RETRIES} from './constants';
5+
import {JWT_EXPIRED_MESSAGE, STATUS} from '../constants/constants';
46

57
/* eslint-disable-next-line no-console */
68
const logWarningOnce = once(console.warn);
@@ -29,8 +31,10 @@ function POST(path, fetchConfig, body = {}) {
2931
const request = {GET, POST};
3032

3133
export default function apiThunk(endpoint, method, store, id, body) {
32-
return (dispatch, getState) => {
33-
const {config} = getState();
34+
return async (dispatch, getState) => {
35+
let {config, hooks} = getState();
36+
let newHeaders = null;
37+
3438
const url = `${urlBase(config)}${endpoint}`;
3539

3640
function setConnectionStatus(connected) {
@@ -46,48 +50,81 @@ export default function apiThunk(endpoint, method, store, id, body) {
4650
type: store,
4751
payload: {id, status: 'loading'}
4852
});
49-
return request[method](url, config.fetch, body)
50-
.then(
51-
res => {
52-
setConnectionStatus(true);
53-
const contentType = res.headers.get('content-type');
54-
if (
55-
contentType &&
56-
contentType.indexOf('application/json') !== -1
57-
) {
58-
return res.json().then(json => {
59-
dispatch({
60-
type: store,
61-
payload: {
62-
status: res.status,
63-
content: json,
64-
id
65-
}
66-
});
67-
return json;
68-
});
53+
54+
try {
55+
let res;
56+
for (let retry = 0; retry <= MAX_AUTH_RETRIES; retry++) {
57+
try {
58+
res = await request[method](url, config.fetch, body);
59+
} catch (e) {
60+
// fetch rejection - this means the request didn't return,
61+
// we don't get here from 400/500 errors, only network
62+
// errors or unresponsive servers.
63+
console.log('fetch error', res);
64+
setConnectionStatus(false);
65+
return;
66+
}
67+
68+
if (res.status === STATUS.UNAUTHORIZED) {
69+
if (hooks.request_refresh_jwt) {
70+
const body = await res.text();
71+
if (body.includes(JWT_EXPIRED_MESSAGE)) {
72+
const newJwt = await hooks.request_refresh_jwt(
73+
config.fetch.headers.Authorization.substr(
74+
'Bearer '.length
75+
)
76+
);
77+
if (newJwt) {
78+
newHeaders = {
79+
Authorization: `Bearer ${newJwt}`
80+
};
81+
82+
config = mergeDeepRight(config, {
83+
fetch: {
84+
headers: newHeaders
85+
}
86+
});
87+
88+
continue;
89+
}
90+
}
6991
}
70-
logWarningOnce(
71-
'Response is missing header: content-type: application/json'
72-
);
73-
return dispatch({
92+
}
93+
break;
94+
}
95+
96+
const contentType = res.headers.get('content-type');
97+
98+
if (newHeaders) {
99+
dispatch(addHttpHeaders(newHeaders));
100+
}
101+
setConnectionStatus(true);
102+
if (contentType && contentType.indexOf('application/json') !== -1) {
103+
return res.json().then(json => {
104+
dispatch({
74105
type: store,
75106
payload: {
76-
id,
77-
status: res.status
107+
status: res.status,
108+
content: json,
109+
id
78110
}
79111
});
80-
},
81-
() => {
82-
// fetch rejection - this means the request didn't return,
83-
// we don't get here from 400/500 errors, only network
84-
// errors or unresponsive servers.
85-
setConnectionStatus(false);
112+
return json;
113+
});
114+
}
115+
logWarningOnce(
116+
'Response is missing header: content-type: application/json'
117+
);
118+
return dispatch({
119+
type: store,
120+
payload: {
121+
id,
122+
status: res.status
86123
}
87-
)
88-
.catch(err => {
89-
const message = 'Error from API call: ' + endpoint;
90-
handleAsyncError(err, message, dispatch);
91124
});
125+
} catch (err) {
126+
const message = 'Error from API call: ' + endpoint;
127+
handleAsyncError(err, message, dispatch);
128+
}
92129
};
93130
}

dash/dash-renderer/src/actions/callbacks.ts

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
zip
1111
} from 'ramda';
1212

13-
import {STATUS} from '../constants/constants';
13+
import {STATUS, JWT_EXPIRED_MESSAGE} from '../constants/constants';
14+
import {MAX_AUTH_RETRIES} from './constants';
1415
import {
1516
CallbackActionType,
1617
CallbackAggregateActionType
@@ -29,6 +30,7 @@ import {isMultiValued, stringifyId, isMultiOutputProp} from './dependencies';
2930
import {urlBase} from './utils';
3031
import {getCSRFHeader} from '.';
3132
import {createAction, Action} from 'redux-actions';
33+
import {addHttpHeaders} from '../actions';
3234

3335
export const addBlockedCallbacks = createAction<IBlockedCallback[]>(
3436
CallbackActionType.AddBlocked
@@ -306,7 +308,7 @@ function handleServerside(
306308
config: any,
307309
payload: any
308310
): Promise<any> {
309-
if (hooks.request_pre !== null) {
311+
if (hooks.request_pre) {
310312
hooks.request_pre(payload);
311313
}
312314

@@ -364,7 +366,7 @@ function handleServerside(
364366
if (status === STATUS.OK) {
365367
return res.json().then((data: any) => {
366368
const {multi, response} = data;
367-
if (hooks.request_post !== null) {
369+
if (hooks.request_post) {
368370
hooks.request_post(payload, response);
369371
}
370372

@@ -488,7 +490,7 @@ export function executeCallback(
488490
};
489491
}
490492

491-
const __promise = new Promise<CallbackResult>(resolve => {
493+
const __execute = async (): Promise<CallbackResult> => {
492494
try {
493495
const payload: ICallbackPayload = {
494496
output,
@@ -502,32 +504,89 @@ export function executeCallback(
502504

503505
if (clientside_function) {
504506
try {
505-
resolve({
507+
return {
506508
data: handleClientside(
507509
dispatch,
508510
clientside_function,
509511
config,
510512
payload
511513
),
512514
payload
513-
});
515+
};
514516
} catch (error) {
515-
resolve({error, payload});
517+
return {error, payload};
516518
}
517-
return null;
518519
}
519520

520-
handleServerside(dispatch, hooks, config, payload)
521-
.then(data => resolve({data, payload}))
522-
.catch(error => resolve({error, payload}));
521+
let newConfig = config;
522+
let newHeaders: Record<string, string> | null = null;
523+
let lastError: any;
524+
525+
for (let retry = 0; retry <= MAX_AUTH_RETRIES; retry++) {
526+
try {
527+
const data = await handleServerside(
528+
dispatch,
529+
hooks,
530+
newConfig,
531+
payload
532+
);
533+
534+
if (newHeaders) {
535+
dispatch(addHttpHeaders(newHeaders));
536+
}
537+
538+
return {data, payload};
539+
} catch (res) {
540+
lastError = res;
541+
if (
542+
retry <= MAX_AUTH_RETRIES &&
543+
res.status === STATUS.UNAUTHORIZED
544+
) {
545+
const body = await res.text();
546+
547+
if (body.includes(JWT_EXPIRED_MESSAGE)) {
548+
if (hooks.request_refresh_jwt !== null) {
549+
let oldJwt = null;
550+
if (config.fetch.headers.Authorization) {
551+
oldJwt =
552+
config.fetch.headers.Authorization.substr(
553+
'Bearer '.length
554+
);
555+
}
556+
557+
const newJwt =
558+
await hooks.request_refresh_jwt(oldJwt);
559+
if (newJwt) {
560+
newHeaders = {
561+
Authorization: `Bearer ${newJwt}`
562+
};
563+
564+
newConfig = mergeDeepRight(config, {
565+
fetch: {
566+
headers: newHeaders
567+
}
568+
});
569+
570+
continue;
571+
}
572+
}
573+
}
574+
}
575+
576+
break;
577+
}
578+
}
579+
580+
// we reach here when we run out of retries.
581+
return {error: lastError, payload: null};
523582
} catch (error) {
524-
resolve({error, payload: null});
583+
return {error, payload: null};
525584
}
526-
});
585+
};
527586

528587
const newCb = {
529588
...cb,
530-
executionPromise: __promise
589+
executionPromise: __execute()
531590
};
532591

533592
return newCb;

dash/dash-renderer/src/actions/constants.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const actionList = {
66
SET_LAYOUT: 1,
77
SET_APP_LIFECYCLE: 1,
88
SET_CONFIG: 1,
9+
ADD_HTTP_HEADERS: 1,
910
ON_ERROR: 1,
1011
SET_HOOKS: 1
1112
};
@@ -16,3 +17,5 @@ export const getAction = action => {
1617
}
1718
throw new Error(`${action} is not defined.`);
1819
};
20+
21+
export const MAX_AUTH_RETRIES = 1;

dash/dash-renderer/src/actions/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {getPath} from './paths';
1111
export const onError = createAction(getAction('ON_ERROR'));
1212
export const setAppLifecycle = createAction(getAction('SET_APP_LIFECYCLE'));
1313
export const setConfig = createAction(getAction('SET_CONFIG'));
14+
export const addHttpHeaders = createAction(getAction('ADD_HTTP_HEADERS'));
1415
export const setGraphs = createAction(getAction('SET_GRAPHS'));
1516
export const setHooks = createAction(getAction('SET_HOOKS'));
1617
export const setLayout = createAction(getAction('SET_LAYOUT'));

dash/dash-renderer/src/constants/constants.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
export const REDIRECT_URI_PATHNAME = '/_oauth2/callback';
22
export const OAUTH_COOKIE_NAME = 'plotly_oauth_token';
3+
export const JWT_EXPIRED_MESSAGE = 'JWT Expired';
34

45
export const STATUS = {
56
OK: 200,
67
PREVENT_UPDATE: 204,
8+
UNAUTHORIZED: 401,
79
CLIENTSIDE_ERROR: 'CLIENTSIDE_ERROR',
810
NO_RESPONSE: 'NO_RESPONSE'
911
};
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import {getAction} from '../actions/constants';
2+
import {mergeDeepRight} from 'ramda';
23

34
export default function config(state = null, action) {
45
if (action.type === getAction('SET_CONFIG')) {
56
return action.payload;
7+
} else if (action.type === getAction('ADD_HTTP_HEADERS')) {
8+
return mergeDeepRight(state, {
9+
fetch: {
10+
headers: action.payload
11+
}
12+
});
613
}
714
return state;
815
}

0 commit comments

Comments
 (0)