allow configuring timeout for remote schema calls (close #2501) (#2753)

This commit is contained in:
Tirumarai Selvan 2019-08-23 04:57:19 -04:00 committed by Vamshi Surabhi
parent 669bb40e72
commit 98784212e2
16 changed files with 218 additions and 55 deletions

View File

@ -103,4 +103,5 @@ if (globals.consoleMode === SERVER_CONSOLE_MODE) {
}
*/
}
export default globals;

View File

@ -21,12 +21,15 @@ const prefixUrl = globals.urlPrefix + appPrefix;
const MANUAL_URL_CHANGED = '@addRemoteSchema/MANUAL_URL_CHANGED';
const ENV_URL_CHANGED = '@addRemoteSchema/ENV_URL_CHANGED';
const NAME_CHANGED = '@addRemoteSchema/NAME_CHANGED';
const TIMEOUT_CONF_CHANGED = '@addRemoteSchema/TIMEOUT_CONF_CHANGED';
// const HEADER_CHANGED = '@addRemoteSchema/HEADER_CHANGED';
const ADDING_REMOTE_SCHEMA = '@addRemoteSchema/ADDING_REMOTE_SCHEMA';
const ADD_REMOTE_SCHEMA_FAIL = '@addRemoteSchema/ADD_REMOTE_SCHEMA_FAIL';
const RESET = '@addRemoteSchema/RESET';
const FETCHING_INDIV_REMOTE_SCHEMA = '@addRemoteSchema/FETCHING_INDIV_REMOTE_SCHEMA';
const REMOTE_SCHEMA_FETCH_SUCCESS = '@addRemoteSchema/REMOTE_SCHEMA_FETCH_SUCCESS';
const FETCHING_INDIV_REMOTE_SCHEMA =
'@addRemoteSchema/FETCHING_INDIV_REMOTE_SCHEMA';
const REMOTE_SCHEMA_FETCH_SUCCESS =
'@addRemoteSchema/REMOTE_SCHEMA_FETCH_SUCCESS';
const REMOTE_SCHEMA_FETCH_FAIL = '@addRemoteSchema/REMOTE_SCHEMA_FETCH_FAIL';
const DELETING_REMOTE_SCHEMA = '@addRemoteSchema/DELETING_REMOTE_SCHEMA';
@ -47,6 +50,7 @@ const inputEventMap = {
name: NAME_CHANGED,
envName: ENV_URL_CHANGED,
manualUrl: MANUAL_URL_CHANGED,
timeoutConf: TIMEOUT_CONF_CHANGED,
};
/* Action creators */
@ -139,12 +143,17 @@ const addRemoteSchema = () => {
return (dispatch, getState) => {
const currState = getState().remoteSchemas.addData;
// const url = Endpoints.getSchema;
let timeoutSeconds = parseInt(currState.timeoutConf, 10);
if (isNaN(timeoutSeconds)) timeoutSeconds = 60;
const resolveObj = {
name: currState.name.trim().replace(/ +/g, ''),
definition: {
url: currState.manualUrl,
url_from_env: currState.envName,
headers: [],
timeout_seconds: timeoutSeconds,
forward_client_headers: currState.forwardClientHeaders,
},
};
@ -316,11 +325,18 @@ const modifyRemoteSchema = () => {
name: currState.editState.originalName,
},
};
let newTimeout = parseInt(currState.timeoutConf, 10);
let oldTimeout = parseInt(currState.editState.originalTimeoutConf, 10);
if (isNaN(newTimeout)) newTimeout = 60;
if (isNaN(oldTimeout)) oldTimeout = 60;
const resolveObj = {
name: remoteSchemaName,
definition: {
url: currState.manualUrl,
url_from_env: currState.envName,
timeout_seconds: newTimeout,
forward_client_headers: currState.forwardClientHeaders,
headers: [],
},
@ -351,11 +367,13 @@ const modifyRemoteSchema = () => {
name: remoteSchemaName,
},
};
const resolveDownObj = {
name: currState.editState.originalName,
definition: {
url: currState.editState.originalUrl,
url_from_env: currState.editState.originalEnvUrl,
timeout_seconds: oldTimeout,
headers: [],
forward_client_headers:
currState.editState.originalForwardClientHeaders,
@ -377,6 +395,7 @@ const modifyRemoteSchema = () => {
...resolveDownObj,
},
};
downQueryArgs.push(deleteRemoteSchemaDown);
downQueryArgs.push(createRemoteSchemaDown);
// End of down
@ -389,6 +408,7 @@ const modifyRemoteSchema = () => {
type: 'bulk',
args: downQueryArgs,
};
const requestMsg = 'Modifying remote schema...';
const successMsg = 'Remote schema modified';
const errorMsg = 'Modify remote schema failed';
@ -441,6 +461,11 @@ const addRemoteSchemaReducer = (state = addState, action) => {
envName: action.data,
manualUrl: null,
};
case TIMEOUT_CONF_CHANGED:
return {
...state,
timeoutConf: action.data,
};
case ADDING_REMOTE_SCHEMA:
return {
...state,
@ -480,6 +505,9 @@ const addRemoteSchemaReducer = (state = addState, action) => {
manualUrl: action.data[0].definition.url || null,
envName: action.data[0].definition.url_from_env || null,
headers: action.data[0].definition.headers || [],
timeoutConf: action.data[0].definition.timeout_seconds
? action.data[0].definition.timeout_seconds.toString()
: '60',
forwardClientHeaders: action.data[0].definition.forward_client_headers,
editState: {
...state,

View File

@ -1,4 +1,6 @@
import React from 'react';
import globals from '../../../../Globals';
import { REMOTE_SCHEMA_TIMEOUT_CONF_SUPPORT } from '../../../../helpers/versionUtils';
import PropTypes from 'prop-types';
import OverlayTrigger from 'react-bootstrap/lib/OverlayTrigger';
import Tooltip from 'react-bootstrap/lib/Tooltip';
@ -36,42 +38,92 @@ class Common extends React.Component {
render() {
const styles = require('../RemoteSchema.scss');
const { name, manualUrl, envName, forwardClientHeaders } = this.props;
const {
name,
manualUrl,
envName,
timeoutConf,
forwardClientHeaders,
} = this.props;
const { isModify, id } = this.props.editState;
const isDisabled = id >= 0 && !isModify;
const urlRequired = !manualUrl && !envName;
const graphqlurl = (
<Tooltip id="tooltip-cascade">
Remote GraphQL servers URL. E.g. https://my-domain/v1/graphql
</Tooltip>
);
const tooltips = {
graphqlurl: (
<Tooltip id="tooltip-cascade">
Remote GraphQL servers URL. E.g. https://my-domain/v1/graphql
</Tooltip>
),
clientHeaderForward: (
<Tooltip id="tooltip-cascade">
Toggle forwarding headers sent by the client app in the request to
your remote GraphQL server
</Tooltip>
),
additionalHeaders: (
<Tooltip id="tooltip-cascade">
Custom headers to be sent to the remote GraphQL server
</Tooltip>
),
schema: (
<Tooltip id="tooltip-cascade">
Give this GraphQL schema a friendly name.
</Tooltip>
),
timeoutConf: (
<Tooltip id="tooltip-cascade">
Configure timeout for your remote GraphQL server. Defaults to 60
seconds.
</Tooltip>
),
};
const clientHeaderForward = (
<Tooltip id="tooltip-cascade">
Toggle forwarding headers sent by the client app in the request to your
remote GraphQL server
</Tooltip>
);
const getTimeoutSection = () => {
const supportTimeoutConf =
globals.featuresCompatibility &&
globals.featuresCompatibility[REMOTE_SCHEMA_TIMEOUT_CONF_SUPPORT];
const additionalHeaders = (
<Tooltip id="tooltip-cascade">
Custom headers to be sent to the remote GraphQL server
</Tooltip>
);
if (!supportTimeoutConf) {
return null;
}
const schema = (
<Tooltip id="tooltip-cascade">
Give this GraphQL schema a friendly name.
</Tooltip>
);
return (
<React.Fragment>
<div className={styles.subheading_text}>
GraphQL server timeout
<OverlayTrigger placement="right" overlay={tooltips.timeoutConf}>
<i className="fa fa-question-circle" aria-hidden="true" />
</OverlayTrigger>
</div>
<label
className={
styles.inputLabel + ' radio-inline ' + styles.padd_left_remove
}
>
<input
className={'form-control'}
type="text"
placeholder="Timeout in seconds"
value={timeoutConf}
data-key="timeoutConf"
onChange={this.handleInputChange.bind(this)}
disabled={isDisabled}
data-test="remote-schema-timeout-conf"
pattern="^\d+$"
title="Only non negative integers are allowed"
/>
</label>
</React.Fragment>
);
};
return (
<div className={styles.CommonWrapper}>
<div className={styles.subheading_text + ' ' + styles.addPaddTop}>
Remote Schema name *
<OverlayTrigger placement="right" overlay={schema}>
<OverlayTrigger placement="right" overlay={tooltips.schema}>
<i className="fa fa-question-circle" aria-hidden="true" />
</OverlayTrigger>
</div>
@ -95,12 +147,12 @@ class Common extends React.Component {
/>
</label>
<hr />
<h4 className={styles.subheading_text}>
<div className={styles.subheading_text}>
GraphQL server URL *
<OverlayTrigger placement="right" overlay={graphqlurl}>
<OverlayTrigger placement="right" overlay={tooltips.graphqlurl}>
<i className="fa fa-question-circle" aria-hidden="true" />
</OverlayTrigger>
</h4>
</div>
<div className={styles.wd_300}>
<DropdownButton
dropdownOptions={[
@ -152,13 +204,19 @@ class Common extends React.Component {
/>
<span>Forward all headers from client</span>
</label>
<OverlayTrigger placement="right" overlay={clientHeaderForward}>
<OverlayTrigger
placement="right"
overlay={tooltips.clientHeaderForward}
>
<i className="fa fa-question-circle" aria-hidden="true" />
</OverlayTrigger>
</div>
<div className={styles.subheading_text + ' ' + styles.font_normal}>
Additional headers:
<OverlayTrigger placement="right" overlay={additionalHeaders}>
<OverlayTrigger
placement="right"
overlay={tooltips.additionalHeaders}
>
<i className="fa fa-question-circle" aria-hidden="true" />
</OverlayTrigger>
</div>
@ -174,6 +232,8 @@ class Common extends React.Component {
placeHolderText={this.getPlaceHolderText.bind(this)}
keyInputPlaceholder="header name"
/>
<hr />
{getTimeoutSection()}
</div>
);
}

View File

@ -17,6 +17,7 @@ const addState = {
manualUrl: '',
envName: null,
headers: [],
timeoutConf: '',
name: '',
forwardClientHeaders: false,
...asyncState,
@ -27,6 +28,7 @@ const addState = {
originalHeaders: [],
originalUrl: '',
originalEnvUrl: '',
originalTimeoutConf: '',
originalForwardClientHeaders: false,
},
};

View File

@ -8,6 +8,7 @@ const envObj = `apiHost: '${process.env.API_HOST}',
enableTelemetry: ${process.env.ENABLE_TELEMETRY},
assetsPath: '${process.env.ASSETS_PATH}',
assetsVersion: '${process.env.ASSETS_VERSION}',
serverVersion: '${process.env.SERVER_VERSION}',
cdnAssets: ${process.env.CDN_ASSETS},
`;

View File

@ -2,12 +2,15 @@ const semver = require('semver');
export const FT_JWT_ANALYZER = 'JWTAnalyzer';
export const RELOAD_METADATA_API_CHANGE = 'reloadMetaDataApiChange';
export const REMOTE_SCHEMA_TIMEOUT_CONF_SUPPORT =
'remoteSchemaTimeoutConfSupport';
// list of feature launch versions
const featureLaunchVersions = {
// feature: 'v1.0.0'
[RELOAD_METADATA_API_CHANGE]: 'v1.0.0-beta.3',
[FT_JWT_ANALYZER]: 'v1.0.0-beta.3',
[REMOTE_SCHEMA_TIMEOUT_CONF_SUPPORT]: 'v1.0.0-beta.5',
};
export const getFeaturesCompatibility = serverVersion => {

View File

@ -30,7 +30,8 @@ An example request as follows:
"definition": {
"url": "https://remote-server.com/graphql",
"headers": [{"name": "X-Server-Request-From", "value": "Hasura"}],
"forward_client_headers": false
"forward_client_headers": false,
"timeout_seconds": 60
},
"comment": "some optional comment"
}

View File

@ -433,7 +433,8 @@ RemoteSchemaDef
"value_from_env": env-var-string
}
],
"forward_client_headers": boolean
"forward_client_headers": boolean,
"timeout_seconds": integer
}
.. _CollectionName:

View File

@ -20,10 +20,10 @@ import Data.Int (Int64)
import Data.IORef (IORef, readIORef)
import Data.Time.Clock
import Hasura.Events.HTTP
import Hasura.HTTP
import Hasura.Prelude
import Hasura.RQL.DDL.Headers
import Hasura.RQL.Types
import Hasura.Server.Version (currentVersion)
import Hasura.SQL.Types
import qualified Control.Concurrent.STM.TQueue as TQ
@ -344,12 +344,6 @@ decodeHeader logenv headerInfos (hdrName, hdrVal)
where
decodeBS = TE.decodeUtf8With TE.lenientDecode
addDefaultHeaders :: [HTTP.Header] -> [HTTP.Header]
addDefaultHeaders hdrs = hdrs ++
[ (CI.mk "Content-Type", "application/json")
, (CI.mk "User-Agent", "hasura-graphql-engine/" <> T.encodeUtf8 currentVersion)
]
mkInvo
:: EventPayload -> Int -> [HeaderConf] -> TBS.TByteString -> [HeaderConf]
-> Invocation

View File

@ -371,19 +371,26 @@ execRemoteGQ reqId userInfo reqHdrs q rsi opDef = do
, Map.fromList userInfoToHdrs
, Map.fromList clientHdrs
]
finalHdrs = foldr Map.union Map.empty hdrMaps
options = wreqOptions manager (Map.toList finalHdrs)
headers = Map.toList $ foldr Map.union Map.empty hdrMaps
finalHeaders = addDefaultHeaders headers
initReqE <- liftIO $ try $ HTTP.parseRequest (show url)
initReq <- either httpThrow pure initReqE
let req = initReq
{ HTTP.method = "POST"
, HTTP.requestHeaders = finalHeaders
, HTTP.requestBody = HTTP.RequestBodyLBS (J.encode q)
, HTTP.responseTimeout = HTTP.responseTimeoutMicro (timeout * 1000000)
}
-- log the graphql query
liftIO $ logGraphqlQuery logger $ QueryLog q Nothing reqId
res <- liftIO $ try $ Wreq.postWith options (show url) (J.toJSON q)
res <- liftIO $ try $ HTTP.httpLbs req manager
resp <- either httpThrow return res
let cookieHdrs = getCookieHdr (resp ^.. Wreq.responseHeader "Set-Cookie")
respHdrs = Just $ mkRespHeaders cookieHdrs
return $ HttpResponse (encJFromLBS $ resp ^. Wreq.responseBody) respHdrs
where
RemoteSchemaInfo url hdrConf fwdClientHdrs = rsi
RemoteSchemaInfo url hdrConf fwdClientHdrs timeout = rsi
httpThrow :: (MonadError QErr m) => HTTP.HttpException -> m a
httpThrow = \case
HTTP.HttpExceptionRequest _req content -> throw500 $ T.pack . show $ content

View File

@ -5,6 +5,7 @@ import Control.Lens ((^.))
import Data.Aeson ((.:), (.:?))
import Data.FileEmbed (embedStringFile)
import Data.Foldable (foldlM)
import Hasura.HTTP
import Hasura.Prelude
import qualified Data.Aeson as J
@ -19,7 +20,6 @@ import qualified Language.GraphQL.Draft.Syntax as G
import qualified Network.HTTP.Client as HTTP
import qualified Network.Wreq as Wreq
import Hasura.HTTP (wreqOptions)
import Hasura.RQL.DDL.Headers (getHeadersFromConf)
import Hasura.RQL.Types
import Hasura.Server.Utils (httpExceptToJSON)
@ -37,12 +37,21 @@ fetchRemoteSchema
-> RemoteSchemaName
-> RemoteSchemaInfo
-> m GC.RemoteGCtx
fetchRemoteSchema manager name def@(RemoteSchemaInfo url headerConf _) = do
fetchRemoteSchema manager name def@(RemoteSchemaInfo url headerConf _ timeout) = do
headers <- getHeadersFromConf headerConf
let hdrs = flip map headers $
\(hn, hv) -> (CI.mk . T.encodeUtf8 $ hn, T.encodeUtf8 hv)
options = wreqOptions manager hdrs
res <- liftIO $ try $ Wreq.postWith options (show url) introspectionQuery
hdrsWithDefaults = addDefaultHeaders hdrs
initReqE <- liftIO $ try $ HTTP.parseRequest (show url)
initReq <- either throwHttpErr pure initReqE
let req = initReq
{ HTTP.method = "POST"
, HTTP.requestHeaders = hdrsWithDefaults
, HTTP.requestBody = HTTP.RequestBodyLBS introspectionQuery
, HTTP.responseTimeout = HTTP.responseTimeoutMicro (timeout * 1000000)
}
res <- liftIO $ try $ HTTP.httpLbs req manager
resp <- either throwHttpErr return res
let respData = resp ^. Wreq.responseBody

View File

@ -2,6 +2,7 @@ module Hasura.HTTP
( wreqOptions
, HttpException(..)
, hdrsToText
, addDefaultHeaders
) where
import Control.Lens hiding ((.=))
@ -25,9 +26,21 @@ hdrsToText hdrs =
wreqOptions :: HTTP.Manager -> [HTTP.Header] -> Wreq.Options
wreqOptions manager hdrs =
Wreq.defaults
& Wreq.headers .~ contentType : userAgent : hdrs
& Wreq.headers .~ addDefaultHeaders hdrs
& Wreq.checkResponse ?~ (\_ _ -> return ())
& Wreq.manager .~ Right manager
-- Adds defaults headers overwriting any existing ones
addDefaultHeaders :: [HTTP.Header] -> [HTTP.Header]
addDefaultHeaders hdrs = defaultHeaders <> rmDefaultHeaders hdrs
where
rmDefaultHeaders = filter (not . isDefaultHeader)
isDefaultHeader :: HTTP.Header -> Bool
isDefaultHeader (hdrName, _) = hdrName `elem` (map fst defaultHeaders)
defaultHeaders :: [HTTP.Header]
defaultHeaders = [contentType, userAgent]
where
contentType = ("Content-Type", "application/json")
userAgent = ( "User-Agent"

View File

@ -30,6 +30,7 @@ data RemoteSchemaInfo
{ rsUrl :: !N.URI
, rsHeaders :: ![HeaderConf]
, rsFwdClientHeaders :: !Bool
, rsTimeoutSeconds :: !Int
} deriving (Show, Eq, Lift, Generic)
instance Hashable RemoteSchemaInfo
@ -42,6 +43,7 @@ data RemoteSchemaDef
, _rsdUrlFromEnv :: !(Maybe UrlFromEnv)
, _rsdHeaders :: !(Maybe [HeaderConf])
, _rsdForwardClientHeaders :: !Bool
, _rsdTimeoutSeconds :: !(Maybe Int)
} deriving (Show, Eq, Lift)
$(J.deriveJSON (J.aesonDrop 4 J.snakeCase) ''RemoteSchemaDef)
@ -77,15 +79,18 @@ validateRemoteSchemaDef
:: (MonadError QErr m, MonadIO m)
=> RemoteSchemaDef
-> m RemoteSchemaInfo
validateRemoteSchemaDef (RemoteSchemaDef mUrl mUrlEnv hdrC fwdHdrs) =
validateRemoteSchemaDef (RemoteSchemaDef mUrl mUrlEnv hdrC fwdHdrs mTimeout) =
case (mUrl, mUrlEnv) of
(Just url, Nothing) ->
return $ RemoteSchemaInfo url hdrs fwdHdrs
return $ RemoteSchemaInfo url hdrs fwdHdrs timeout
(Nothing, Just urlEnv) -> do
url <- getUrlFromEnv urlEnv
return $ RemoteSchemaInfo url hdrs fwdHdrs
return $ RemoteSchemaInfo url hdrs fwdHdrs timeout
(Nothing, Nothing) ->
throw400 InvalidParams "both `url` and `url_from_env` can't be empty"
(Just _, Just _) ->
throw400 InvalidParams "both `url` and `url_from_env` can't be present"
where hdrs = fromMaybe [] hdrC
where
hdrs = fromMaybe [] hdrC
timeout = fromMaybe 60 mTimeout

View File

@ -10,6 +10,8 @@ from webserver import RequestHandler, WebServer, MkHandlers, Response
from enum import Enum
import time
def mkJSONResp(graphql_result):
return Response(HTTPStatus.OK, graphql_result.to_dict(),
{'Content-Type': 'application/json'})
@ -25,9 +27,15 @@ class HelloWorldHandler(RequestHandler):
class Hello(graphene.ObjectType):
hello = graphene.String(arg=graphene.String(default_value="world"))
delayedHello = graphene.String(arg=graphene.String(default_value="world"))
def resolve_hello(self, info, arg):
return "Hello " + arg
def resolve_delayedHello(self, info, arg):
time.sleep(10)
return "Hello " + arg
hello_schema = graphene.Schema(query=Hello, subscription=Hello)
class HelloGraphQL(RequestHandler):

View File

@ -0,0 +1,12 @@
description: Simple GraphQL query (takes 10s to respond)
url: /v1/graphql
status: 200
response:
errors:
- message: ResponseTimeout
extensions: {'path': '$', 'code': 'unexpected'}
query:
query: |
query {
delayedHello(arg: "me")
}

View File

@ -6,13 +6,14 @@ import yaml
import json
import queue
import requests
import time
import pytest
from validate import check_query_f, check_query
def mk_add_remote_q(name, url, headers=None, client_hdrs=False):
def mk_add_remote_q(name, url, headers=None, client_hdrs=False, timeout=None):
return {
"type": "add_remote_schema",
"args": {
@ -21,7 +22,8 @@ def mk_add_remote_q(name, url, headers=None, client_hdrs=False):
"definition": {
"url": url,
"headers": headers,
"forward_client_headers": client_hdrs
"forward_client_headers": client_hdrs,
"timeout_seconds": timeout
}
}
}
@ -434,6 +436,22 @@ class TestAddRemoteSchemaCompareRootQueryFields:
compare_flds(fldH, fldR)
assert has_fld[fldR['name']], 'Field ' + fldR['name'] + ' in the remote shema root query type not found in Hasura schema'
class TestRemoteSchemaTimeout:
dir = 'queries/remote_schemas'
teardown = {"type": "clear_metadata", "args": {}}
@pytest.fixture(autouse=True)
def transact(self, hge_ctx):
q = mk_add_remote_q('simple 1', 'http://localhost:5000/hello-graphql', timeout = 5)
st_code, resp = hge_ctx.v1q(q)
assert st_code == 200, resp
yield
hge_ctx.v1q(self.teardown)
def test_remote_query_timeout(self, hge_ctx):
check_query_f(hge_ctx, self.dir + '/basic_timeout_query.yaml')
# wait for graphql server to finish else teardown throws
time.sleep(6)
# def test_remote_query_variables(self, hge_ctx):
# pass