Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
### Added
- [#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.
- [#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.
- [#1736](https://github.com/plotly/dash/pull/1736) Add support for `request_refresh_jwt` hook and retry requests that used expired JWT tokens.

### Changed
- [#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.
Expand Down
19 changes: 16 additions & 3 deletions dash/dash-renderer/src/AppContainer.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,29 @@ import Loading from './components/core/Loading.react';
import Toolbar from './components/core/Toolbar.react';
import Reloader from './components/core/Reloader.react';
import {setHooks, setConfig} from './actions/index';
import {type} from 'ramda';
import {type, memoizeWith, identity} from 'ramda';

class UnconnectedAppContainer extends React.Component {
constructor(props) {
super(props);
if (
props.hooks.request_pre !== null ||
props.hooks.request_post !== null
props.hooks.request_post !== null ||
props.hooks.request_refresh_jwt !== null
) {
props.dispatch(setHooks(props.hooks));
let hooks = props.hooks;

if (hooks.request_refresh_jwt) {
hooks = {
...hooks,
request_refresh_jwt: memoizeWith(
identity,
hooks.request_refresh_jwt
)
};
}

props.dispatch(setHooks(hooks));
}
}

Expand Down
6 changes: 4 additions & 2 deletions dash/dash-renderer/src/AppProvider.react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ const AppProvider = ({hooks}: any) => {
AppProvider.propTypes = {
hooks: PropTypes.shape({
request_pre: PropTypes.func,
request_post: PropTypes.func
request_post: PropTypes.func,
request_refresh_jwt: PropTypes.func
})
};

AppProvider.defaultProps = {
hooks: {
request_pre: null,
request_post: null
request_post: null,
request_refresh_jwt: null
}
};

Expand Down
115 changes: 76 additions & 39 deletions dash/dash-renderer/src/actions/api.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {mergeDeepRight, once} from 'ramda';
import {handleAsyncError, getCSRFHeader} from '../actions';
import {getCSRFHeader, handleAsyncError, addHttpHeaders} from '../actions';
import {urlBase} from './utils';
import {MAX_AUTH_RETRIES} from './constants';
import {JWT_EXPIRED_MESSAGE, STATUS} from '../constants/constants';

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

export default function apiThunk(endpoint, method, store, id, body) {
return (dispatch, getState) => {
const {config} = getState();
return async (dispatch, getState) => {
let {config, hooks} = getState();
let newHeaders = null;

const url = `${urlBase(config)}${endpoint}`;

function setConnectionStatus(connected) {
Expand All @@ -46,48 +50,81 @@ export default function apiThunk(endpoint, method, store, id, body) {
type: store,
payload: {id, status: 'loading'}
});
return request[method](url, config.fetch, body)
.then(
res => {
setConnectionStatus(true);
const contentType = res.headers.get('content-type');
if (
contentType &&
contentType.indexOf('application/json') !== -1
) {
return res.json().then(json => {
dispatch({
type: store,
payload: {
status: res.status,
content: json,
id
}
});
return json;
});

try {
let res;
for (let retry = 0; retry <= MAX_AUTH_RETRIES; retry++) {
try {
res = await request[method](url, config.fetch, body);
} catch (e) {
// fetch rejection - this means the request didn't return,
// we don't get here from 400/500 errors, only network
// errors or unresponsive servers.
console.log('fetch error', res);
setConnectionStatus(false);
return;
}

if (res.status === STATUS.UNAUTHORIZED) {
if (hooks.request_refresh_jwt) {
const body = await res.text();
if (body.includes(JWT_EXPIRED_MESSAGE)) {
const newJwt = await hooks.request_refresh_jwt(
config.fetch.headers.Authorization.substr(
'Bearer '.length
)
);
if (newJwt) {
newHeaders = {
Authorization: `Bearer ${newJwt}`
};

config = mergeDeepRight(config, {
fetch: {
headers: newHeaders
}
});

continue;
}
}
}
logWarningOnce(
'Response is missing header: content-type: application/json'
);
return dispatch({
}
break;
}

const contentType = res.headers.get('content-type');

if (newHeaders) {
dispatch(addHttpHeaders(newHeaders));
}
setConnectionStatus(true);
if (contentType && contentType.indexOf('application/json') !== -1) {
return res.json().then(json => {
dispatch({
type: store,
payload: {
id,
status: res.status
status: res.status,
content: json,
id
}
});
},
() => {
// fetch rejection - this means the request didn't return,
// we don't get here from 400/500 errors, only network
// errors or unresponsive servers.
setConnectionStatus(false);
return json;
});
}
logWarningOnce(
'Response is missing header: content-type: application/json'
);
return dispatch({
type: store,
payload: {
id,
status: res.status
}
)
.catch(err => {
const message = 'Error from API call: ' + endpoint;
handleAsyncError(err, message, dispatch);
});
} catch (err) {
const message = 'Error from API call: ' + endpoint;
handleAsyncError(err, message, dispatch);
}
};
}
87 changes: 73 additions & 14 deletions dash/dash-renderer/src/actions/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
zip
} from 'ramda';

import {STATUS} from '../constants/constants';
import {STATUS, JWT_EXPIRED_MESSAGE} from '../constants/constants';
import {MAX_AUTH_RETRIES} from './constants';
import {
CallbackActionType,
CallbackAggregateActionType
Expand All @@ -29,6 +30,7 @@ import {isMultiValued, stringifyId, isMultiOutputProp} from './dependencies';
import {urlBase} from './utils';
import {getCSRFHeader} from '.';
import {createAction, Action} from 'redux-actions';
import {addHttpHeaders} from '../actions';

export const addBlockedCallbacks = createAction<IBlockedCallback[]>(
CallbackActionType.AddBlocked
Expand Down Expand Up @@ -306,7 +308,7 @@ function handleServerside(
config: any,
payload: any
): Promise<any> {
if (hooks.request_pre !== null) {
if (hooks.request_pre) {
hooks.request_pre(payload);
}

Expand Down Expand Up @@ -364,7 +366,7 @@ function handleServerside(
if (status === STATUS.OK) {
return res.json().then((data: any) => {
const {multi, response} = data;
if (hooks.request_post !== null) {
if (hooks.request_post) {
hooks.request_post(payload, response);
}

Expand Down Expand Up @@ -488,7 +490,7 @@ export function executeCallback(
};
}

const __promise = new Promise<CallbackResult>(resolve => {
const __execute = async (): Promise<CallbackResult> => {
try {
const payload: ICallbackPayload = {
output,
Expand All @@ -502,32 +504,89 @@ export function executeCallback(

if (clientside_function) {
try {
resolve({
return {
data: handleClientside(
dispatch,
clientside_function,
config,
payload
),
payload
});
};
} catch (error) {
resolve({error, payload});
return {error, payload};
}
return null;
}

handleServerside(dispatch, hooks, config, payload)
.then(data => resolve({data, payload}))
.catch(error => resolve({error, payload}));
let newConfig = config;
let newHeaders: Record<string, string> | null = null;
let lastError: any;

for (let retry = 0; retry <= MAX_AUTH_RETRIES; retry++) {
try {
const data = await handleServerside(
dispatch,
hooks,
newConfig,
payload
);

if (newHeaders) {
dispatch(addHttpHeaders(newHeaders));
}

return {data, payload};
} catch (res) {
lastError = res;
if (
retry <= MAX_AUTH_RETRIES &&
res.status === STATUS.UNAUTHORIZED
) {
const body = await res.text();

if (body.includes(JWT_EXPIRED_MESSAGE)) {
if (hooks.request_refresh_jwt !== null) {
let oldJwt = null;
if (config.fetch.headers.Authorization) {
oldJwt =
config.fetch.headers.Authorization.substr(
'Bearer '.length
);
}

const newJwt =
await hooks.request_refresh_jwt(oldJwt);
if (newJwt) {
newHeaders = {
Authorization: `Bearer ${newJwt}`
};

newConfig = mergeDeepRight(config, {
fetch: {
headers: newHeaders
}
});

continue;
}
}
}
}

break;
}
}

// we reach here when we run out of retries.
return {error: lastError, payload: null};
} catch (error) {
resolve({error, payload: null});
return {error, payload: null};
}
});
};

const newCb = {
...cb,
executionPromise: __promise
executionPromise: __execute()
};

return newCb;
Expand Down
3 changes: 3 additions & 0 deletions dash/dash-renderer/src/actions/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const actionList = {
SET_LAYOUT: 1,
SET_APP_LIFECYCLE: 1,
SET_CONFIG: 1,
ADD_HTTP_HEADERS: 1,
ON_ERROR: 1,
SET_HOOKS: 1
};
Expand All @@ -16,3 +17,5 @@ export const getAction = action => {
}
throw new Error(`${action} is not defined.`);
};

export const MAX_AUTH_RETRIES = 1;
1 change: 1 addition & 0 deletions dash/dash-renderer/src/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {getPath} from './paths';
export const onError = createAction(getAction('ON_ERROR'));
export const setAppLifecycle = createAction(getAction('SET_APP_LIFECYCLE'));
export const setConfig = createAction(getAction('SET_CONFIG'));
export const addHttpHeaders = createAction(getAction('ADD_HTTP_HEADERS'));
export const setGraphs = createAction(getAction('SET_GRAPHS'));
export const setHooks = createAction(getAction('SET_HOOKS'));
export const setLayout = createAction(getAction('SET_LAYOUT'));
Expand Down
2 changes: 2 additions & 0 deletions dash/dash-renderer/src/constants/constants.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
export const REDIRECT_URI_PATHNAME = '/_oauth2/callback';
export const OAUTH_COOKIE_NAME = 'plotly_oauth_token';
export const JWT_EXPIRED_MESSAGE = 'JWT Expired';

export const STATUS = {
OK: 200,
PREVENT_UPDATE: 204,
UNAUTHORIZED: 401,
CLIENTSIDE_ERROR: 'CLIENTSIDE_ERROR',
NO_RESPONSE: 'NO_RESPONSE'
};
Expand Down
7 changes: 7 additions & 0 deletions dash/dash-renderer/src/reducers/config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import {getAction} from '../actions/constants';
import {mergeDeepRight} from 'ramda';

export default function config(state = null, action) {
if (action.type === getAction('SET_CONFIG')) {
return action.payload;
} else if (action.type === getAction('ADD_HTTP_HEADERS')) {
return mergeDeepRight(state, {
fetch: {
headers: action.payload
}
});
}
return state;
}
Loading