diff --git a/lib/connection/connection.js b/lib/connection/connection.js index 3620b178c..39627a4ee 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -714,6 +714,7 @@ function Connection(context) { url: Url.format({ pathname: `/monitoring/queries/${queryId}`, }), + useExperimentalRetryMiddleware: true, }; Logger.getInstance().debug( diff --git a/lib/connection/statement.js b/lib/connection/statement.js index 0bf315b6d..f294d679d 100644 --- a/lib/connection/statement.js +++ b/lib/connection/statement.js @@ -1271,6 +1271,7 @@ function sendCancelStatement(statementContext, statement, callback) { method: 'POST', url: url, json: json, + useExperimentalRetryMiddleware: true, callback: function (err) { // if a callback was specified, invoke it if (Util.isFunction(callback)) { @@ -1547,7 +1548,11 @@ function sendSfRequest(statementContext, options, appendQueryParamOnRetry) { options.url = Util.url.appendRetryParam(retryOption); } - sf.request(options); + sf.request({ + ...options, + // TODO: debugging + useExperimentalRetryMiddleware: true, + }); }; // replace the specified callback with a new one that retries diff --git a/lib/http/axiosInstance.ts b/lib/http/axiosInstance.ts new file mode 100644 index 000000000..ee2e4f2f6 --- /dev/null +++ b/lib/http/axiosInstance.ts @@ -0,0 +1,85 @@ +import axiosLib, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import * as Util from '../util'; +import Logger from '../logger'; +import * as requestUtil from './request_util'; + +type SnowflakeAxiosRequestConfig = InternalAxiosRequestConfig & { + useExperimentalRetryMiddleware?: boolean; + __snowflakeRetryConfig?: { + numRetries: number; + totalElapsedTime: number; + startingSleepTime: number; + maxNumRetries: number; + maxRetryTimeout: number; + }; +}; + +const axios = axiosLib.create(); + +/* + * NOTE: + * This interceptor enables request retries when useExperimentalRetryMiddleware=true. + * + * It's marked as experimental, because it doesn't handle retry customization ATM and is intended + * to be used with endpoints that have no other retry handling. + * + * Future improvements: + * - Handle retry customization (similar to axios-retry library) + * - Support retry telemetry (retryCount and retryReason query params) + * - Consider replacing code with axios-retry + * - Support abort signal + */ +axios.interceptors.response.use( + (response) => response, + async (err: AxiosError) => { + const config = err.config ? (err.config as SnowflakeAxiosRequestConfig) : null; + if (!config || !config.useExperimentalRetryMiddleware) { + return Promise.reject(err); + } + + config.__snowflakeRetryConfig ??= { + numRetries: 1, + totalElapsedTime: 0, + startingSleepTime: 1, + maxNumRetries: 7, + maxRetryTimeout: 300, + }; + const { numRetries, totalElapsedTime, startingSleepTime, maxNumRetries, maxRetryTimeout } = + config.__snowflakeRetryConfig; + + // TODO: + // - ensure test coverage for isRetryableNetworkError and isRetryableHttpError + // - check if we handle redirects + // - probably need to retry timeouts + test coverage + const isRetryable = err.response + ? Util.isRetryableHttpError({ statusCode: err.response.status }, false) + : true; // TODO: ['ERR_CANCELED', 'ECONNABORTED']; this 2 should be ignored + + if (isRetryable && numRetries <= maxNumRetries && totalElapsedTime <= maxRetryTimeout) { + // TODO: + // Util.nextSleepTime mighe be better? + const jitter = Util.getJitteredSleepTime( + numRetries, + startingSleepTime, + totalElapsedTime, + maxRetryTimeout, + ); + config.__snowflakeRetryConfig.totalElapsedTime = jitter.totalElapsedTime; + config.__snowflakeRetryConfig.numRetries++; + + Logger().debug( + 'useExperimentalRetryMiddleware: Retrying request%s - error=%s, attempt=%s, delay=%ss', + requestUtil.describeRequestFromOptions(config), + err.message, + numRetries, + jitter.sleep, + ); + await Util.sleep(jitter.sleep * 1000); + return axios.request(config); + } else { + return Promise.reject(err); + } + }, +); + +export default axios; diff --git a/lib/http/base.js b/lib/http/base.js index 1ec3730e9..46124eb7b 100644 --- a/lib/http/base.js +++ b/lib/http/base.js @@ -2,9 +2,9 @@ const zlib = require('zlib'); const Util = require('../util'); const Logger = require('../logger'); const ExecutionTimer = require('../logger/execution_timer'); -const axios = require('axios'); const URL = require('node:url').URL; const requestUtil = require('./request_util'); +const axios = require('./axiosInstance').default; const DEFAULT_REQUEST_TIMEOUT = 360000; @@ -358,6 +358,9 @@ function prepareRequestOptions(options, requestHandlers = {}) { agentClass: this._connectionConfig.agentClass, }; } + + // NOTE: + // backoffStrategy is a dead code that is not used in actual retry logic const backoffStrategy = this.constructExponentialBackoffStrategy(); const requestOptions = { method: options.method, @@ -372,6 +375,7 @@ function prepareRequestOptions(options, requestHandlers = {}) { // we manually parse jsons or other structures from the server so they need to be text responseType: options.responseType || 'text', proxy: false, + useExperimentalRetryMiddleware: options.useExperimentalRetryMiddleware, ...requestHandlers, }; diff --git a/lib/services/sf.js b/lib/services/sf.js index 401d8e5e0..e1da83970 100644 --- a/lib/services/sf.js +++ b/lib/services/sf.js @@ -604,6 +604,7 @@ function StateAbstract(options) { gzip: requestOptions.gzip, json: requestOptions.json, params: params, + useExperimentalRetryMiddleware: requestOptions.useExperimentalRetryMiddleware, callback: async function (err, response, body) { // if we got an error, wrap it into a network error if (err) { @@ -1418,6 +1419,7 @@ StateConnected.prototype.destroy = function (options) { method: 'POST', url: `/session?delete=true&requestId=${requestID}`, scope: this, + useExperimentalRetryMiddleware: true, callback: function (err) { // if the destroy request succeeded or the session already expired, we're disconnected if ( diff --git a/lib/util.ts b/lib/util.ts index 70a6bce09..2c6f0d3e2 100644 --- a/lib/util.ts +++ b/lib/util.ts @@ -703,6 +703,10 @@ export function escapeHTML(value: string) { .replace(/'/g, '''); } +export function sleep(sleepTimeMs: number) { + return new Promise((resolve) => setTimeout(resolve, sleepTimeMs)); +} + /** * Typescript with "module": "commonjs" will transform every import() to a require() statement. * diff --git a/test/integration/testCancel.js b/test/integration/testCancel.js index e191e3434..0fbaf43eb 100644 --- a/test/integration/testCancel.js +++ b/test/integration/testCancel.js @@ -1,6 +1,9 @@ const async = require('async'); const testUtil = require('./testUtil'); +const snowflake = require('./../../lib/snowflake'); +snowflake.configure({ logLevel: 'trace' }); + describe('Test Cancel Query', function () { let connection; const longQuery = 'select count(*) from table(generator(timeLimit => 3600))'; diff --git a/test/integration/testHTAP.js b/test/integration/testHTAP.js index 2c7479da7..7bb360bb0 100644 --- a/test/integration/testHTAP.js +++ b/test/integration/testHTAP.js @@ -1,14 +1,17 @@ const assert = require('assert'); const async = require('async'); +const uuid = require('uuid'); const connOption = require('./connectionOptions').valid; const testUtil = require('./testUtil'); -function getRandomDBNames() { - const dbName = 'qcc_test_db'; +// TODO: remove me +const snowflake = require('./../../lib/snowflake'); +snowflake.configure({ logLevel: 'trace' }); + +function getUniqueDBNames(amount = 3) { const arr = []; - const randomNumber = Math.floor(Math.random() * 10000); - for (let i = 0; i < 3; i++) { - arr.push(dbName + (randomNumber + i)); + for (let i = 0; i < amount; i++) { + arr.push(`qcc_test_db_${Date.now()}_${uuid.v4().replaceAll('-', '_')}`); } return arr; } @@ -16,9 +19,10 @@ function getRandomDBNames() { // Only the AWS servers support the hybrid table in the GitHub action. if (process.env.CLOUD_PROVIDER === 'AWS') { describe('Query Context Cache test', function () { - this.retries(3); + this.timeout(5 * 60 * 1000); + let connection; - const dbNames = getRandomDBNames(); + const dbNames = getUniqueDBNames(); beforeEach(async () => { connection = testUtil.createConnection(connOption); @@ -75,10 +79,7 @@ if (process.env.CLOUD_PROVIDER === 'AWS') { testingfunction = function (callback) { connection.execute({ sqlText: sqlTexts[k], - complete: function (err) { - assert.ok(!err, 'There should be no error!'); - callback(); - }, + complete: callback, }); }; } else { @@ -86,10 +87,13 @@ if (process.env.CLOUD_PROVIDER === 'AWS') { connection.execute({ sqlText: sqlTexts[k], complete: function (err, stmt) { - assert.ok(!err, 'There should be no error!'); - assert.strictEqual(stmt.getQueryContextCacheSize(), QccSize); - assert.strictEqual(stmt.getQueryContextDTOSize(), QccSize); - callback(); + if (err) { + callback(err); + } else { + assert.strictEqual(stmt.getQueryContextCacheSize(), QccSize); + assert.strictEqual(stmt.getQueryContextDTOSize(), QccSize); + callback(); + } }, }); }; diff --git a/test/integration/testLargeResultSet.js b/test/integration/testLargeResultSet.js index 89bc60cb0..30182a87e 100644 --- a/test/integration/testLargeResultSet.js +++ b/test/integration/testLargeResultSet.js @@ -259,7 +259,7 @@ describe('SNOW-743920:Large result set with ~35 chunks', function () { } else { stmt .streamRows() - .on('error', () => done(err)) + .on('error', (streamErr) => done(streamErr)) .on('data', (row) => rows.push(row)) .on('end', () => { try { diff --git a/test/integration/testOcsp.js b/test/integration/testOcsp.js index 2ad6850d6..a0f3e4cde 100644 --- a/test/integration/testOcsp.js +++ b/test/integration/testOcsp.js @@ -1,18 +1,15 @@ const Os = require('os'); const async = require('async'); const assert = require('assert'); +const { exec } = require('child_process'); const snowflake = require('./../../lib/snowflake'); const connOption = require('./connectionOptions'); const SocketUtil = require('./../../lib/agent/socket_util'); const OcspResponseCache = require('./../../lib/agent/ocsp_response_cache'); const Check = require('./../../lib/agent/check'); const Util = require('./../../lib/util'); -const { exec } = require('child_process'); -const testUtil = require('./testUtil'); - -const sharedLogger = require('./sharedLogger'); const Logger = require('./../../lib/logger'); -Logger.getInstance().setLogger(sharedLogger.logger); +const testUtil = require('./testUtil'); describe('OCSP validation', function () { it('OCSP validation with server reusing SSL sessions', function (done) { @@ -25,13 +22,9 @@ describe('OCSP validation', function () { async.series( [ function (callback) { - connection.connect(function (err) { - assert.ok(!err, JSON.stringify(err)); - callback(); - }); + connection.connect(callback); }, function (callback) { - let numErrors = 0; let numStmtsExecuted = 0; const numStmtsTotal = 20; @@ -42,12 +35,10 @@ describe('OCSP validation', function () { sqlText: 'select 1;', complete: function (err) { if (err) { - numErrors++; + callback(err); } - numStmtsExecuted++; - if (numStmtsExecuted === numStmtsTotal - 1) { - assert.strictEqual(numErrors, 0); + if (numStmtsExecuted === numStmtsTotal) { callback(); } }, @@ -71,13 +62,9 @@ describe('OCSP validation', function () { async.series( [ function (callback) { - connection.connect(function (err) { - assert.ok(!err, JSON.stringify(err)); - callback(); - }); + connection.connect(callback); }, function (callback) { - let numErrors = 0; let numStmtsExecuted = 0; const numStmtsTotal = 5; @@ -89,13 +76,12 @@ describe('OCSP validation', function () { sqlText: 'select 1;', complete: function (err) { if (err) { - numErrors++; + callback(err); } numStmtsExecuted++; - if (numStmtsExecuted === numStmtsTotal - 1) { + if (numStmtsExecuted === numStmtsTotal) { delete process.env['SF_OCSP_TEST_CACHE_MAXAGE']; - assert.strictEqual(numErrors, 0); callback(); } }, diff --git a/test/integration/wiremock/testRequestRetry.ts b/test/integration/wiremock/testRequestRetry.ts new file mode 100644 index 000000000..4e67b6831 --- /dev/null +++ b/test/integration/wiremock/testRequestRetry.ts @@ -0,0 +1,201 @@ +import { WireMockRestClient } from 'wiremock-rest-client'; +import axios from 'axios'; +import assert from 'assert'; +import sinon from 'sinon'; +import { runWireMockAsync, addWireMockMappingsFromFile } from '../../wiremockRunner'; +import * as testUtil from '../testUtil'; +import * as Util from '../../../lib/util'; +import axiosInstance from '../../../lib/http/axiosInstance'; + +// TODO: remove this after done debugging +const snowflake = require('../../../lib/snowflake'); +snowflake.configure({ + logLevel: 'TRACE', +}); + +const RETRYABABLE_NETWORK_FAULTS = [ + 'EMPTY_RESPONSE', + 'MALFORMED_RESPONSE_CHUNK', + 'RANDOM_DATA_THEN_CLOSE', + 'CONNECTION_RESET_BY_PEER', +]; +const RETRYABLE_HTTP_CODES = [408, 429, 500, 503]; + +// TODO: should test network timeouts??? + +// NOTE: +// For every wiremock scenario, we do 4 retries: 3 failures and 1 success. +// This ensures that the full retry flow with backoff is working correctly, as we had bugs +// where the retry would be executed only once. +describe('Request retries', () => { + let wiremock: WireMockRestClient; + let port: number; + let connection: any; + let axiosRequestSpy: sinon.SinonSpy; + + before(async () => { + port = await testUtil.getFreePort(); + wiremock = await runWireMockAsync(port); + + // TODO: temporary + snowflake.configure({ + disableOCSPChecks: true, + }); + }); + + beforeEach(async () => { + axiosRequestSpy = sinon.spy(axiosInstance, 'request'); + // NOTE: + // retryTimeout config has 300s minimum, so mock backoff for fast test retries + sinon + .stub(Util, 'getJitteredSleepTime') + .callsFake( + ( + _numRetries: number, + _currentSleepTime: number, + totalElapsedTime: number, + _maxRetryTimeout: number, + ) => { + const sleep = 0.1; // 100ms + const newTotalElapsedTime = totalElapsedTime + sleep; + return { sleep, totalElapsedTime: newTotalElapsedTime }; + }, + ); + + connection = testUtil.createConnection({ + accessUrl: `http://127.0.0.1:${port}`, + // TODO: remove before merge + // proxyHost: '127.0.0.1', + // proxyPort: 8080, + }); + await addWireMockMappingsFromFile(wiremock, 'wiremock/mappings/login_request_ok.json'); + await addWireMockMappingsFromFile(wiremock, 'wiremock/mappings/session_delete_ok.json'); + }); + + afterEach(async () => { + sinon.restore(); + await testUtil.destroyConnectionAsync(connection); + await wiremock.mappings.resetAllMappings(); + }); + + after(async () => { + await wiremock.global.shutdown(); + // TODO: temporary + snowflake.configure({ + disableOCSPChecks: false, + }); + }); + + function getAxiosRequestsCount(matchingPath: string) { + return axiosRequestSpy.getCalls().filter((c: any) => c.args?.[0]?.url?.includes(matchingPath)) + .length; + } + + // TODO: test difference is only wiremock mapping, so .forEach blocks should be merged + RETRYABABLE_NETWORK_FAULTS.forEach((responseFault) => { + it(`Login request retries on network fault=${responseFault}`, async () => { + await addWireMockMappingsFromFile( + wiremock, + 'wiremock/mappings/errors/login_request_network_fail.json', + { + responseFault, + }, + ); + await testUtil.connectAsync(connection); + testUtil.assertConnectionActive(connection); + assert.strictEqual(getAxiosRequestsCount('/session/v1/login-request'), 4); + }); + + it(`Cancel query retries on network fault=${responseFault}`, async () => { + await addWireMockMappingsFromFile( + wiremock, + 'wiremock/mappings/errors/cancel_query_network_fail.json', + { + responseFault, + }, + ); + await testUtil.connectAsync(connection); + const statement = connection.execute({ sqlText: 'SELECT 1' }); + await new Promise((resolve, reject) => { + statement.cancel((err: any) => (err ? reject(err) : resolve(null))); + }); + assert.strictEqual(getAxiosRequestsCount('/queries/v1/abort-request'), 4); + }); + }); + + RETRYABLE_HTTP_CODES.forEach((httpStatusCode) => { + it(`Login request retries on status=${httpStatusCode}`, async () => { + await addWireMockMappingsFromFile( + wiremock, + 'wiremock/mappings/errors/login_request_server_fail.json', + { + httpStatusCode, + }, + ); + await testUtil.connectAsync(connection); + testUtil.assertConnectionActive(connection); + assert.strictEqual(getAxiosRequestsCount('/session/v1/login-request'), 4); + }); + + it(`Cancel query retries on network status=${httpStatusCode}`, async () => { + await addWireMockMappingsFromFile( + wiremock, + 'wiremock/mappings/errors/cancel_query_server_fail.json', + { + httpStatusCode, + }, + ); + await testUtil.connectAsync(connection); + const statement = connection.execute({ sqlText: 'SELECT 1' }); + await new Promise((resolve, reject) => { + statement.cancel((err: any) => (err ? reject(err) : resolve(null))); + }); + assert.strictEqual(getAxiosRequestsCount('/queries/v1/abort-request'), 4); + }); + }); + + // it(`Query request retries on ${fault}`, async () => { + // await addWireMockMappingsFromFile( + // wiremock, + // 'wiremock/mappings/network_errors/query_request.json', + // { + // queryNetworkErrorFault: fault, + // }, + // ); + // await testUtil.connectAsync(connection); + // await testUtil.executeCmdAsync(connection, 'SELECT 1'); + // }); + + // it(`Query result retries on ${fault}`, async () => { + // await addWireMockMappingsFromFile( + // wiremock, + // // TODO: put query id as a variable + // 'wiremock/mappings/network_errors/query_result.json', + // { + // queryResultNetworkErrorFault: fault, + // }, + // ); + // await testUtil.connectAsync(connection); + // await connection.getResultsFromQueryId({ + // queryId: '01234567-89ab-cdef-0123-456789abcdef', + // }); + // }); + + // it(`Query request with large chunks retries chunk download on ${fault}`, async () => { + // await addWireMockMappingsFromFile( + // wiremock, + // 'wiremock/mappings/network_errors/query_request_large_chunks.json', + // { + // wiremockPort: port, + // chunkNetworkErrorFault: fault, + // }, + // ); + // sinon + // .stub(Util, 'nextSleepTime') + // .callsFake((_base: number, _cap: number, _previousSleep: number) => 0.1); + // await testUtil.connectAsync(connection); + // const data = await testUtil.executeCmdAsync(connection, 'SELECT 1'); + // console.log('DATA ----->', data); + // }); + // }); +}); diff --git a/test/unit/mock/mock_http_client.js b/test/unit/mock/mock_http_client.js index 93cce4775..0d4ba7664 100644 --- a/test/unit/mock/mock_http_client.js +++ b/test/unit/mock/mock_http_client.js @@ -150,6 +150,7 @@ function serializeRequest(request) { // { url: 'foo', method: 'GET' } even though they are semantically equivalent // requests, i.e. they should produce the same output const clonedRequest = createSortedClone(request); + delete clonedRequest.useExperimentalRetryMiddleware; // Ignore CLIENT_ENVIRONMENT for now, // in future we should migrate this entire thing to wiremock for better matchers if (clonedRequest.json && clonedRequest.json.data && clonedRequest.json.data.CLIENT_ENVIRONMENT) { diff --git a/test/wiremockRunner.js b/test/wiremockRunner.js index 7904e8d61..9e783164d 100644 --- a/test/wiremockRunner.js +++ b/test/wiremockRunner.js @@ -77,8 +77,32 @@ async function waitForWiremockStarted(wireMock, counter) { }); } -async function addWireMockMappingsFromFile(wireMock, filePath) { - const requests = JSON.parse(fs.readFileSync(filePath, 'utf8')); +/** + * Adds WireMock mappings from a JSON file with support for template variable replacement. + * + * Template variables in the file can be specified using double curly braces with optional spaces: + * - {{variable1}} - no spaces around variable name + * - {{ variable1 }} - spaces around variable name + * + * @param {Object} wireMock - The WireMock REST client instance + * @param {string} filePath - Path to the JSON file containing WireMock mappings + * @param {Object} [fileVariables={}] - Object containing key-value pairs for template variable replacement + */ +async function addWireMockMappingsFromFile(wireMock, filePath, fileVariables = {}) { + let fileContent = fs.readFileSync(filePath, 'utf8'); + + // Replace template variables in the file content + // Regex matches {{variable}} or {{ variable }} with optional whitespace + fileContent = fileContent.replace(/\{\{\s*([^}]+)\s*\}\}/g, (match, variableName) => { + const trimmedVariableName = variableName.trim(); + if (fileVariables[trimmedVariableName]) { + return fileVariables[trimmedVariableName]; + } + // If variable is not found, leave the placeholder unchanged + return match; + }); + + const requests = JSON.parse(fileContent); for (const mapping of requests.mappings) { await wireMock.mappings.createMapping(mapping); } diff --git a/wiremock/mappings/errors/cancel_query_network_fail.json b/wiremock/mappings/errors/cancel_query_network_fail.json new file mode 100644 index 000000000..d9ad40659 --- /dev/null +++ b/wiremock/mappings/errors/cancel_query_network_fail.json @@ -0,0 +1,69 @@ +{ + "mappings": [ + { + "scenarioName": "Cancel query fails 3 times due to network fault before success", + "request": { + "urlPathPattern": "/queries/v1/query-request*", + "method": "POST" + }, + "response": { + "fixedDelayMilliseconds": 30000 + } + }, + { + "scenarioName": "Cancel query fails 3 times due to network fault before success", + "newScenarioState": "CancelFailed1", + "request": { + "urlPathPattern": "/queries/v1/abort-request*", + "method": "POST" + }, + "response": { + "fault": "{{responseFault}}" + } + }, + { + "scenarioName": "Cancel query fails 3 times due to network fault before success", + "newScenarioState": "CancelFailed2", + "requiredScenarioState": "CancelFailed1", + "request": { + "urlPathPattern": "/queries/v1/abort-request*", + "method": "POST" + }, + "response": { + "fault": "{{responseFault}}" + } + }, + { + "scenarioName": "Cancel query fails 3 times due to network fault before success", + "requiredScenarioState": "CancelFailed2", + "newScenarioState": "CancelFailed3", + "request": { + "urlPathPattern": "/queries/v1/abort-request*", + "method": "POST" + }, + "response": { + "fault": "{{responseFault}}" + } + }, + { + "scenarioName": "Cancel query fails 3 times due to network fault before success", + "requiredScenarioState": "CancelFailed3", + "request": { + "urlPathPattern": "/queries/v1/abort-request*", + "method": "POST" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "code": null, + "data": null, + "message": null, + "success": true + } + } + } + ] +} diff --git a/wiremock/mappings/errors/cancel_query_server_fail.json b/wiremock/mappings/errors/cancel_query_server_fail.json new file mode 100644 index 000000000..c5fcde4d1 --- /dev/null +++ b/wiremock/mappings/errors/cancel_query_server_fail.json @@ -0,0 +1,96 @@ +{ + "mappings": [ + { + "scenarioName": "Cancel query fails 3 times due to server error before success", + "request": { + "urlPathPattern": "/queries/v1/query-request*", + "method": "POST" + }, + "response": { + "fixedDelayMilliseconds": 30000 + } + }, + { + "scenarioName": "Cancel query fails 3 times due to network fault before success", + "newScenarioState": "CancelFailed1", + "request": { + "urlPathPattern": "/queries/v1/abort-request*", + "method": "POST" + }, + "response": { + "status": "{{httpStatusCode}}", + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "code": null, + "data": null, + "message": null, + "success": false + } + } + }, + { + "scenarioName": "Cancel query fails 3 times due to network fault before success", + "requiredScenarioState": "CancelFailed1", + "newScenarioState": "CancelFailed2", + "request": { + "urlPathPattern": "/queries/v1/abort-request*", + "method": "POST" + }, + "response": { + "status": "{{httpStatusCode}}", + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "code": null, + "data": null, + "message": null, + "success": false + } + } + }, + { + "scenarioName": "Cancel query fails 3 times due to network fault before success", + "requiredScenarioState": "CancelFailed2", + "newScenarioState": "CancelFailed3", + "request": { + "urlPathPattern": "/queries/v1/abort-request*", + "method": "POST" + }, + "response": { + "status": "{{httpStatusCode}}", + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "code": null, + "data": null, + "message": null, + "success": false + } + } + }, + { + "scenarioName": "Cancel query fails 3 times due to network fault before success", + "requiredScenarioState": "CancelFailed3", + "request": { + "urlPathPattern": "/queries/v1/abort-request*", + "method": "POST" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "code": null, + "data": null, + "message": null, + "success": true + } + } + } + ] +} diff --git a/wiremock/mappings/errors/login_request_network_fail.json b/wiremock/mappings/errors/login_request_network_fail.json new file mode 100644 index 000000000..741134060 --- /dev/null +++ b/wiremock/mappings/errors/login_request_network_fail.json @@ -0,0 +1,40 @@ +{ + "mappings": [ + { + "scenarioName": "Login fails 3 times due to network fault before success", + "newScenarioState": "Login1Failed", + "requiredScenarioState": "Started", + "request": { + "urlPathPattern": "/session/v1/login-request*", + "method": "POST" + }, + "response": { + "fault": "{{responseFault}}" + } + }, + { + "scenarioName": "Login fails 3 times due to network fault before success", + "newScenarioState": "Login2Failed", + "requiredScenarioState": "Login1Failed", + "request": { + "urlPathPattern": "/session/v1/login-request*", + "method": "POST" + }, + "response": { + "fault": "{{responseFault}}" + } + }, + { + "scenarioName": "Login fails 3 times due to network fault before success", + "requiredScenarioState": "Login2Failed", + "newScenarioState": "PlaceholderToForceDefaultLoginSuccess", + "request": { + "urlPathPattern": "/session/v1/login-request*", + "method": "POST" + }, + "response": { + "fault": "{{responseFault}}" + } + } + ] +} diff --git a/wiremock/mappings/errors/login_request_server_fail.json b/wiremock/mappings/errors/login_request_server_fail.json new file mode 100644 index 000000000..ca9be13f7 --- /dev/null +++ b/wiremock/mappings/errors/login_request_server_fail.json @@ -0,0 +1,67 @@ +{ + "mappings": [ + { + "scenarioName": "Login fails 3 times due to server error before success", + "newScenarioState": "ServerFailed1", + "requiredScenarioState": "Started", + "request": { + "urlPathPattern": "/session/v1/login-request*", + "method": "POST" + }, + "response": { + "status": "{{httpStatusCode}}", + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "code": null, + "data": null, + "message": null, + "success": false + } + } + }, + { + "scenarioName": "Login fails 3 times due to server error before success", + "newScenarioState": "ServerFailed2", + "requiredScenarioState": "ServerFailed1", + "request": { + "urlPathPattern": "/session/v1/login-request*", + "method": "POST" + }, + "response": { + "status": "{{httpStatusCode}}", + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "code": null, + "data": null, + "message": null, + "success": false + } + } + }, + { + "scenarioName": "Login fails 3 times due to server error before success", + "requiredScenarioState": "ServerFailed2", + "newScenarioState": "PlaceholderToForceDefaultLoginSuccess", + "request": { + "urlPathPattern": "/session/v1/login-request*", + "method": "POST" + }, + "response": { + "status": "{{httpStatusCode}}", + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "code": null, + "data": null, + "message": null, + "success": false + } + } + } + ] +} diff --git a/wiremock/mappings/errors/query_request.json b/wiremock/mappings/errors/query_request.json new file mode 100644 index 000000000..42229ca5f --- /dev/null +++ b/wiremock/mappings/errors/query_request.json @@ -0,0 +1,131 @@ +{ + "mappings": [ + { + "request": { + "urlPathPattern": "/session/v1/login-request*", + "method": "POST" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "data": { + "masterToken": "master token", + "token": "session token", + "validityInSeconds": 3600, + "masterValidityInSeconds": 14400, + "displayUserName": "PAT_TEST_USER", + "serverVersion": "8.48.0", + "firstLogin": false, + "remMeToken": null, + "remMeValidityInSeconds": 0, + "healthCheckInterval": 45, + "newClientForUpgrade": "3.12.3", + "sessionId": 1172562260498, + "parameters": [ + { "name": "CLIENT_PREFETCH_THREADS", "value": 4 }, + { "name": "QUERY_CONTEXT_CACHE_SIZE", "value": 5 }, + { "name": "CLIENT_RESULT_PREFETCH_THREADS", "value": 1 } + ], + "sessionInfo": { + "databaseName": "TEST_DATABASE", + "schemaName": "TEST_SCHEMA", + "warehouseName": "TEST_WAREHOUSE", + "roleName": "ANALYST" + }, + "idToken": null, + "idTokenValidityInSeconds": 0, + "responseData": null, + "mfaToken": null, + "mfaTokenValidityInSeconds": 0 + }, + "code": null, + "message": null, + "success": true + } + } + }, + { + "request": { + "urlPathPattern": "/session*", + "method": "POST" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "code": null, + "data": null, + "message": null, + "success": true + } + } + }, + { + "scenarioName": "Query fails multiple times", + "newScenarioState": "Query1Failed", + "request": { + "urlPathPattern": "/queries/v1/query-request*", + "method": "POST" + }, + "response": { + "fault": "{{queryNetworkErrorFault}}" + } + }, + { + "scenarioName": "Query fails multiple times", + "requiredScenarioState": "Query1Failed", + "newScenarioState": "Query2Failed", + "request": { + "urlPathPattern": "/queries/v1/query-request*", + "method": "POST" + }, + "response": { + "fault": "{{queryNetworkErrorFault}}" + } + }, + { + "scenarioName": "Query fails multiple times", + "requiredScenarioState": "Query2Failed", + "request": { + "urlPathPattern": "/queries/v1/query-request*", + "method": "POST" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "data": { + "parameters": [], + "rowtype": [ + { + "name": "1", + "type": "fixed", + "scale": 0, + "precision": 1, + "nullable": false + } + ], + "rowset": [["1"]], + "total": 1, + "returned": 1, + "queryId": "01baf79b-0108-1a60-0000-retry-success", + "numberOfBinds": 0, + "arrayBindSupported": false, + "statementTypeId": 4096, + "version": 1, + "queryResultFormat": "json", + "queryContext": { "entries": [] } + }, + "success": true + } + } + } + ] +} diff --git a/wiremock/mappings/errors/query_request_large_chunks.json b/wiremock/mappings/errors/query_request_large_chunks.json new file mode 100644 index 000000000..be895f722 --- /dev/null +++ b/wiremock/mappings/errors/query_request_large_chunks.json @@ -0,0 +1,161 @@ +{ + "mappings": [ + { + "request": { + "urlPathPattern": "/session/v1/login-request*", + "method": "POST" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "data": { + "masterToken": "master token", + "token": "session token", + "validityInSeconds": 3600, + "masterValidityInSeconds": 14400, + "displayUserName": "PAT_TEST_USER", + "serverVersion": "8.48.0", + "firstLogin": false, + "remMeToken": null, + "remMeValidityInSeconds": 0, + "healthCheckInterval": 45, + "newClientForUpgrade": "3.12.3", + "sessionId": 1172562260498, + "parameters": [ + { "name": "CLIENT_PREFETCH_THREADS", "value": 4 }, + { "name": "QUERY_CONTEXT_CACHE_SIZE", "value": 5 }, + { "name": "CLIENT_RESULT_PREFETCH_THREADS", "value": 1 } + ], + "sessionInfo": { + "databaseName": "TEST_DATABASE", + "schemaName": "TEST_SCHEMA", + "warehouseName": "TEST_WAREHOUSE", + "roleName": "ANALYST" + }, + "idToken": null, + "idTokenValidityInSeconds": 0, + "responseData": null, + "mfaToken": null, + "mfaTokenValidityInSeconds": 0 + }, + "code": null, + "message": null, + "success": true + } + } + }, + { + "request": { + "urlPathPattern": "/session*", + "method": "POST" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "code": null, + "data": null, + "message": null, + "success": true + } + } + }, + { + "request": { + "urlPathPattern": "/queries/v1/query-request*", + "method": "POST" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "data": { + "parameters": [], + "rowtype": [ + { + "name": "1", + "type": "fixed", + "scale": 0, + "precision": 1, + "nullable": false + } + ], + "rowset": [], + "total": 2, + "returned": 2, + "queryId": "01baf79b-0108-1a60-0000-large-chunks", + "numberOfBinds": 0, + "arrayBindSupported": false, + "statementTypeId": 4096, + "version": 1, + "queryResultFormat": "json", + "queryContext": { "entries": [] }, + "chunks": [ + { "rowCount": 1, "url": "http://127.0.0.1:{{wiremockPort}}/chunk/1" }, + { "rowCount": 1, "url": "http://127.0.0.1:{{wiremockPort}}/chunk/2" } + ] + }, + "success": true + } + } + }, + { + "request": { + "urlPathPattern": "/chunk/1", + "method": "GET" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "[\"1\"]" + } + }, + { + "scenarioName": "Chunk2 fails twice then succeeds", + "newScenarioState": "Chunk2FailedOnce", + "request": { + "urlPathPattern": "/chunk/2", + "method": "GET" + }, + "response": { + "fault": "{{chunkNetworkErrorFault}}" + } + }, + { + "scenarioName": "Chunk2 fails twice then succeeds", + "requiredScenarioState": "Chunk2FailedOnce", + "newScenarioState": "Chunk2FailedTwice", + "request": { + "urlPathPattern": "/chunk/2", + "method": "GET" + }, + "response": { + "fault": "{{chunkNetworkErrorFault}}" + } + }, + { + "scenarioName": "Chunk2 fails twice then succeeds", + "requiredScenarioState": "Chunk2FailedTwice", + "request": { + "urlPathPattern": "/chunk/2", + "method": "GET" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "[\"2\"]" + } + } + ] +} diff --git a/wiremock/mappings/errors/query_result.json b/wiremock/mappings/errors/query_result.json new file mode 100644 index 000000000..df5ab41b9 --- /dev/null +++ b/wiremock/mappings/errors/query_result.json @@ -0,0 +1,159 @@ +{ + "mappings": [ + { + "request": { + "urlPathPattern": "/session/v1/login-request*", + "method": "POST" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "data": { + "masterToken": "master token", + "token": "session token", + "validityInSeconds": 3600, + "masterValidityInSeconds": 14400, + "displayUserName": "PAT_TEST_USER", + "serverVersion": "8.48.0", + "firstLogin": false, + "remMeToken": null, + "remMeValidityInSeconds": 0, + "healthCheckInterval": 45, + "newClientForUpgrade": "3.12.3", + "sessionId": 1172562260498, + "parameters": [ + { "name": "CLIENT_PREFETCH_THREADS", "value": 4 }, + { "name": "QUERY_CONTEXT_CACHE_SIZE", "value": 5 }, + { "name": "CLIENT_RESULT_PREFETCH_THREADS", "value": 1 } + ], + "sessionInfo": { + "databaseName": "TEST_DATABASE", + "schemaName": "TEST_SCHEMA", + "warehouseName": "TEST_WAREHOUSE", + "roleName": "ANALYST" + }, + "idToken": null, + "idTokenValidityInSeconds": 0, + "responseData": null, + "mfaToken": null, + "mfaTokenValidityInSeconds": 0 + }, + "code": null, + "message": null, + "success": true + } + } + }, + { + "request": { + "urlPathPattern": "/session*", + "method": "POST" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "code": null, + "data": null, + "message": null, + "success": true + } + } + }, + { + "scenarioName": "Query fails multiple times", + "newScenarioState": "Monitoring1Failed", + "request": { + "urlPathPattern": "/monitoring/queries/01234567-89ab-cdef-0123-456789abcdef*", + "method": "GET" + }, + "response": { + "fault": "{{queryResultNetworkErrorFault}}" + } + }, + { + "scenarioName": "Query fails multiple times", + "requiredScenarioState": "Monitoring1Failed", + "newScenarioState": "MonitoringSucceeded", + "request": { + "urlPathPattern": "/monitoring/queries/01234567-89ab-cdef-0123-456789abcdef*", + "method": "GET" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "success": true, + "code": null, + "message": null, + "data": { + "queries": [ + { + "status": "SUCCESS" + } + ], + "sqlState": null + } + } + } + }, + { + "scenarioName": "Query fails multiple times", + "requiredScenarioState": "MonitoringSucceeded", + "newScenarioState": "Result1Failed", + "request": { + "urlPathPattern": "/queries/01234567-89ab-cdef-0123-456789abcdef/result*", + "method": "GET" + }, + "response": { + "fault": "{{queryResultNetworkErrorFault}}" + } + }, + { + "scenarioName": "Query fails multiple times", + "requiredScenarioState": "Result1Failed", + "request": { + "urlPathPattern": "/queries/01234567-89ab-cdef-0123-456789abcdef/result*", + "method": "GET" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "data": { + "parameters": [], + "rowtype": [ + { + "name": "1", + "type": "fixed", + "scale": 0, + "precision": 1, + "nullable": false + } + ], + "rowset": [["1"]], + "total": 1, + "returned": 1, + "queryId": "01234567-89ab-cdef-0123-456789abcdef", + "numberOfBinds": 0, + "arrayBindSupported": false, + "statementTypeId": 4096, + "version": 1, + "queryResultFormat": "json", + "queryContext": { "entries": [] } + }, + "success": true + } + } + } + ] +} diff --git a/wiremock/mappings/login_request_ok.json b/wiremock/mappings/login_request_ok.json new file mode 100644 index 000000000..9a783aa87 --- /dev/null +++ b/wiremock/mappings/login_request_ok.json @@ -0,0 +1,51 @@ +{ + "mappings": [ + { + "request": { + "urlPathPattern": "/session/v1/login-request*", + "method": "POST" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "data": { + "masterToken": "master token", + "token": "session token", + "validityInSeconds": 3600, + "masterValidityInSeconds": 14400, + "displayUserName": "PAT_TEST_USER", + "serverVersion": "8.48.0", + "firstLogin": false, + "remMeToken": null, + "remMeValidityInSeconds": 0, + "healthCheckInterval": 45, + "newClientForUpgrade": "3.12.3", + "sessionId": 1172562260498, + "parameters": [ + { "name": "CLIENT_PREFETCH_THREADS", "value": 4 }, + { "name": "QUERY_CONTEXT_CACHE_SIZE", "value": 5 }, + { "name": "CLIENT_RESULT_PREFETCH_THREADS", "value": 1 } + ], + "sessionInfo": { + "databaseName": "TEST_DATABASE", + "schemaName": "TEST_SCHEMA", + "warehouseName": "TEST_WAREHOUSE", + "roleName": "ANALYST" + }, + "idToken": null, + "idTokenValidityInSeconds": 0, + "responseData": null, + "mfaToken": null, + "mfaTokenValidityInSeconds": 0 + }, + "code": null, + "message": null, + "success": true + } + } + } + ] +} diff --git a/wiremock/mappings/session_delete_ok.json b/wiremock/mappings/session_delete_ok.json new file mode 100644 index 000000000..b8c0325a6 --- /dev/null +++ b/wiremock/mappings/session_delete_ok.json @@ -0,0 +1,27 @@ +{ + "mappings": [ + { + "request": { + "urlPath": "/session", + "method": "POST", + "queryParameters": { + "delete": { + "equalTo": "true" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "code": null, + "data": null, + "message": null, + "success": true + } + } + } + ] +}