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 = (
const tooltips = {
graphqlurl: (
<Tooltip id="tooltip-cascade">
Remote GraphQL servers URL. E.g. https://my-domain/v1/graphql
</Tooltip>
);
const clientHeaderForward = (
),
clientHeaderForward: (
<Tooltip id="tooltip-cascade">
Toggle forwarding headers sent by the client app in the request to your
remote GraphQL server
Toggle forwarding headers sent by the client app in the request to
your remote GraphQL server
</Tooltip>
);
const additionalHeaders = (
),
additionalHeaders: (
<Tooltip id="tooltip-cascade">
Custom headers to be sent to the remote GraphQL server
</Tooltip>
);
const schema = (
),
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 getTimeoutSection = () => {
const supportTimeoutConf =
globals.featuresCompatibility &&
globals.featuresCompatibility[REMOTE_SCHEMA_TIMEOUT_CONF_SUPPORT];
if (!supportTimeoutConf) {
return null;
}
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