diff --git a/src/sap.ui.core/src/sap/ui/model/odata/v4/ODataModel.js b/src/sap.ui.core/src/sap/ui/model/odata/v4/ODataModel.js index 14979e3c88ad..cf6f5ead9027 100644 --- a/src/sap.ui.core/src/sap/ui/model/odata/v4/ODataModel.js +++ b/src/sap.ui.core/src/sap/ui/model/odata/v4/ODataModel.js @@ -355,10 +355,11 @@ sap.ui.define([ this.mMetadataHeaders = {"Accept-Language" : sLanguageTag}; mQueryParams = Object.assign({}, mUriParameters, mParameters.metadataUrlParams); + const fnGetOrCreateRetryAfterPromise = this.getOrCreateRetryAfterPromise.bind(this); this.oMetaModel = new ODataMetaModel( _MetadataRequestor.create(this.mMetadataHeaders, sODataVersion, mParameters.ignoreAnnotationsFromMetadata, mQueryParams, - mParameters.withCredentials), + mParameters.withCredentials, fnGetOrCreateRetryAfterPromise), this.sServiceUrl + "$metadata", mParameters.annotationURI, this, mParameters.supportReferences, mQueryParams["sap-language"]); this.oInterface = { @@ -372,10 +373,8 @@ sap.ui.define([ getGroupProperty : this.getGroupProperty.bind(this), getMessagesByPath : this.getMessagesByPath.bind(this), getOptimisticBatchEnabler : this.getOptimisticBatchEnabler.bind(this), + getOrCreateRetryAfterPromise : fnGetOrCreateRetryAfterPromise, getReporter : this.getReporter.bind(this), - getRetryAfterHandler : function () { - return that.fnRetryAfter; - }, isIgnoreETag : function () { return that.bIgnoreETag; }, @@ -428,6 +427,7 @@ sap.ui.define([ // ensure the events are respectively fired once for a GET request this.mPath2DataRequestedCount = {}; this.fnRetryAfter = null; + this.oRetryAfterPromise = null; } /** @@ -1642,6 +1642,8 @@ sap.ui.define([ this.oRequestor.destroy(); this.mHeaders = undefined; this.mMetadataHeaders = undefined; + this.oRetryAfterPromise = undefined; + return Model.prototype.destroy.apply(this, arguments); }; @@ -2092,6 +2094,35 @@ sap.ui.define([ return this.fnOptimisticBatchEnabler; }; + /** + * Returns the promise that is currently being used for "Retry-After" handling. Returns + * null if no "Retry-After" is currently known. Creates a new promise if there is + * none, an error is given, and a {@link #setRetryAfterHandler handler} is known. + * + * @param {Error} [oRetryAfterError] - A "Retry-After" error from a backend call + * @returns {Promise|null} The current "Retry-After" promise + * + * @private + */ + ODataModel.prototype.getOrCreateRetryAfterPromise = function (oRetryAfterError) { + if (!this.oRetryAfterPromise && this.fnRetryAfter && oRetryAfterError) { + this.oRetryAfterPromise = this.fnRetryAfter(oRetryAfterError); + this.oRetryAfterPromise.finally(() => { + this.oRetryAfterPromise = null; + }).catch(() => { /* catch is only needed due to finally */ }); + this.oRetryAfterPromise.catch((oError) => { + // own error reason is not reported to the message model + if (oError === oRetryAfterError) { + this.reportError(oError.message, sClassName, oError); + } else { + oError.$reported = true; + } + }); + } + + return this.oRetryAfterPromise; + }; + /** * Method not supported * diff --git a/src/sap.ui.core/src/sap/ui/model/odata/v4/lib/_MetadataRequestor.js b/src/sap.ui.core/src/sap/ui/model/odata/v4/lib/_MetadataRequestor.js index 9053c07b004c..8237bc41008b 100644 --- a/src/sap.ui.core/src/sap/ui/model/odata/v4/lib/_MetadataRequestor.js +++ b/src/sap.ui.core/src/sap/ui/model/odata/v4/lib/_MetadataRequestor.js @@ -28,17 +28,20 @@ sap.ui.define([ * is deleted(!) after the first read for a metadata document. * @param {boolean} [bWithCredentials] * Whether the XHR should be called with withCredentials + * @param {function} fnGetOrCreateRetryAfterPromise + * A function that returns or creates the "Retry-After" promise * @returns {object} * A new MetadataRequestor object */ create : function (mHeaders, sODataVersion, bIgnoreAnnotationsFromMetadata, mQueryParams, - bWithCredentials) { + bWithCredentials, fnGetOrCreateRetryAfterPromise) { var mUrl2Promise = {}, sQuery = _Helper.buildQuery(mQueryParams); return { /** - * Reads a metadata document from the given URL. + * Reads a metadata document from the given URL, taking care of "Retry-After". + * * @param {string} sUrl * The URL of a metadata document, it must not contain a query string or a * fragment part @@ -84,38 +87,53 @@ sap.ui.define([ delete mUrl2Promise[sUrl]; } else { oPromise = new Promise(function (fnResolve, fnReject) { - const oAjaxSettings = { - method : "GET", - headers : mHeaders - }; - if (bWithCredentials) { - oAjaxSettings.xhrFields = {withCredentials : true}; - } + function send() { + const oAjaxSettings = { + method : "GET", + headers : mHeaders + }; + if (bWithCredentials) { + oAjaxSettings.xhrFields = {withCredentials : true}; + } + jQuery.ajax(bAnnotations ? sUrl : sUrl + sQuery, oAjaxSettings) + .then(function (oData, _sTextStatus, jqXHR) { + var sDate = jqXHR.getResponseHeader("Date"), + sETag = jqXHR.getResponseHeader("ETag"), + oJSON = {$XML : oData}, + sLastModified = jqXHR.getResponseHeader("Last-Modified"); - jQuery.ajax(bAnnotations ? sUrl : sUrl + sQuery, oAjaxSettings) - .then(function (oData, _sTextStatus, jqXHR) { - var sDate = jqXHR.getResponseHeader("Date"), - sETag = jqXHR.getResponseHeader("ETag"), - oJSON = {$XML : oData}, - sLastModified = jqXHR.getResponseHeader("Last-Modified"); + if (sDate) { + oJSON.$Date = sDate; + } + if (sETag) { + oJSON.$ETag = sETag; + } + if (sLastModified) { + oJSON.$LastModified = sLastModified; + } + fnResolve(oJSON); + }, function (jqXHR) { + var oError + = _Helper.createError(jqXHR, "Could not load metadata"); - if (sDate) { - oJSON.$Date = sDate; - } - if (sETag) { - oJSON.$ETag = sETag; - } - if (sLastModified) { - oJSON.$LastModified = sLastModified; - } - fnResolve(oJSON); - }, function (jqXHR, _sTextStatus, _sErrorMessage) { - var oError = _Helper.createError(jqXHR, "Could not load metadata"); + if (jqXHR.status === 503 + && jqXHR.getResponseHeader("Retry-After") + && fnGetOrCreateRetryAfterPromise(oError)) { + fnGetOrCreateRetryAfterPromise().then(send, fnReject); + } else { + Log.error("GET " + sUrl, oError.message, + "sap.ui.model.odata.v4.lib._MetadataRequestor"); + fnReject(oError); + } + }); + } - Log.error("GET " + sUrl, oError.message, - "sap.ui.model.odata.v4.lib._MetadataRequestor"); - fnReject(oError); - }); + const oRetryAfterPromise = fnGetOrCreateRetryAfterPromise(); + if (oRetryAfterPromise) { + oRetryAfterPromise.then(send, fnReject); + } else { + send(); + } if (!bAnnotations && mQueryParams && "sap-context-token" in mQueryParams) { delete mQueryParams["sap-context-token"]; diff --git a/src/sap.ui.core/src/sap/ui/model/odata/v4/lib/_Requestor.js b/src/sap.ui.core/src/sap/ui/model/odata/v4/lib/_Requestor.js index 5e61280dc795..9dff2a7e83f6 100644 --- a/src/sap.ui.core/src/sap/ui/model/odata/v4/lib/_Requestor.js +++ b/src/sap.ui.core/src/sap/ui/model/odata/v4/lib/_Requestor.js @@ -89,7 +89,6 @@ sap.ui.define([ this.oModelInterface = oModelInterface; this.oOptimisticBatch = null; // optimistic batch processing off this.sQueryParams = _Helper.buildQuery(mQueryParams); // Used for $batch and CSRF token only - this.oRetryAfterPromise = null; this.mRunningChangeRequests = {}; // map from group ID to a SyncPromise[] this.iSessionTimer = 0; this.iSerialNumber = 0; @@ -2080,26 +2079,9 @@ sap.ui.define([ send(true); }, fnReject); } else if (jqXHR.status === 503 && jqXHR.getResponseHeader("Retry-After") - && (that.oRetryAfterPromise - || that.oModelInterface.getRetryAfterHandler())) { - if (!that.oRetryAfterPromise) { - const oRetryAfterError = _Helper.createError(jqXHR, ""); - that.oRetryAfterPromise = that.oModelInterface.getRetryAfterHandler()( - oRetryAfterError); - that.oRetryAfterPromise.finally(() => { - that.oRetryAfterPromise = null; - }).catch(() => { /* catch is only needed due to finally */ }); - that.oRetryAfterPromise.catch((oError) => { - // own error reason is not reported to the message model - if (oError === oRetryAfterError) { - that.oModelInterface - .reportError(oError.message, sClassName, oError); - } else { - oError.$reported = true; - } - }); - } - that.oRetryAfterPromise.then(send, fnReject); + && that.oModelInterface.getOrCreateRetryAfterPromise( + _Helper.createError(jqXHR, ""))) { + that.oModelInterface.getOrCreateRetryAfterPromise().then(send, fnReject); } else { sMessage = "Communication error"; if (sContextId) { @@ -2119,8 +2101,9 @@ sap.ui.define([ }); } - if (that.oRetryAfterPromise) { - that.oRetryAfterPromise.then(send, fnReject); + const oRetryAfterPromise = that.oModelInterface.getOrCreateRetryAfterPromise(); + if (oRetryAfterPromise) { + oRetryAfterPromise.then(send, fnReject); } else if (that.oSecurityTokenPromise && sMethod !== "GET") { that.oSecurityTokenPromise.then(send); } else { @@ -2335,13 +2318,12 @@ sap.ui.define([ * @param {function} oModelInterface.getOptimisticBatchEnabler * A function that returns a callback function which controls the optimistic batch handling, * see also {@link sap.ui.model.odata.v4.ODataModel#setOptimisticBatchEnabler} + * @param {function} oModelInterface.getOrCreateRetryAfterPromise + * A function that returns or creates the "Retry-After" promise * @param {function} oModelInterface.getReporter * A catch handler function expecting an Error instance. This function will call * {@link sap.ui.model.odata.v4.ODataModel#reportError} if the error has not been reported * yet - * @param {function} oModelInterface.getRetryAfterHandler - * A function that returns the "Retry-After" handler, - * see also {@link sap.ui.model.odata.v4.ODataModel#setRetryAfterHandler} * @param {function():boolean} oModelInterface.isIgnoreETag * Tells whether an entity's ETag should be actively ignored (If-Match:*) for PATCH requests. * @param {function} oModelInterface.onCreateGroup diff --git a/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataMetaModel.qunit.js b/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataMetaModel.qunit.js index 464c97453910..121d44eea10c 100644 --- a/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataMetaModel.qunit.js +++ b/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataMetaModel.qunit.js @@ -5500,7 +5500,7 @@ sap.ui.define([ // Note: "ab-CD" is derived from Localization.getLanguageTag here, not from mHeaders! this.mock(_MetadataRequestor).expects("create") .withExactArgs({"Accept-Language" : "ab-CD"}, "4.0", undefined, - {"sap-language" : "~sLanguage~"}, undefined); + {"sap-language" : "~sLanguage~"}, undefined, sinon.match.func); const oCopyAnnotationsExpectation = this.mock(ODataMetaModel.prototype).expects("_copyAnnotations") .withExactArgs(sinon.match.same(oMetaModel)); @@ -5545,7 +5545,8 @@ sap.ui.define([ .withArgs(false + "/Foo1/ValueListService/").callThrough(); // observe metadataUrlParams NOT being passed along this.mock(_MetadataRequestor).expects("create") - .withExactArgs({"Accept-Language" : "ab-CD"}, "4.0", undefined, {}, undefined); + .withExactArgs({"Accept-Language" : "ab-CD"}, "4.0", undefined, {}, undefined, + sinon.match.func); // code under test oSharedModel = oMetaModel.getOrCreateSharedModel("../ValueListService/$metadata", diff --git a/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataModel.integration.qunit.js b/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataModel.integration.qunit.js index 9b2a112cc275..7c86b5a7d0d4 100644 --- a/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataModel.integration.qunit.js +++ b/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataModel.integration.qunit.js @@ -9476,6 +9476,86 @@ sap.ui.define([ TestUtils.onRequest(null); }); + //********************************************************************************************* + // Scenario: "Retry-After" handling: A request for $metadata is the 1st to encounter 503. + // JIRA: CPOUI5ODATAV4-2770 + QUnit.test('503, "Retry-After" handling: $metadata', async function (assert) { + const aBatchPayloads = []; + TestUtils.onRequest((sPayload, sRequestLine) => { + aBatchPayloads.push(sPayload || sRequestLine); + }); + + let bAnswerWith503 = false; + const oModel = this.createModel(sTeaBusi, {}, { + // initial GET works just fine + [sTeaBusi + "$metadata"] : {source : "odata/v4/data/metadata.xml"}, + "/sap/opu/odata4/IWBEP/TEA/default/iwbep/tea_busi_product/0001/$metadata" + : [{ // cross-service reference may fail at first try + code : 503, + headers : {"Retry-After" : "42"}, + ifMatch : () => bAnswerWith503, + message : { + error : {code : "DB_MIGRATION", message : "DB migration in progress"} + } + }, { + source : "odata/v4/data/metadata_tea_busi_product.xml" + }] + }); + oModel.$keepSend = true; // do not stub sendBatch/-Request + + let iCallbackCount = 0; + let fnResolveRetryAfter; + oModel.setRetryAfterHandler((oError) => { + iCallbackCount += 1; + assert.ok(oError instanceof Error); + assert.strictEqual(oError.message, "DB migration in progress"); + const iSeconds = (oError.retryAfter.getTime() - Date.now()) / 1000; + assert.ok(iSeconds > 41 && iSeconds < 43, `${iSeconds} roughly 42 seconds`); + return new Promise((resolve) => { + fnResolveRetryAfter = resolve; + }); + }); + + await this.createView(assert, "", oModel); + + assert.deepEqual(aBatchPayloads, [], "no requests yet"); + + assert.strictEqual( + await oModel.getMetaModel().requestObject("/TEAMS/MEMBER_COUNT/$Type"), + "Edm.Int32"); + + assert.strictEqual(aBatchPayloads.shift(), "GET " + sTeaBusi + "$metadata", + "main $metadata"); + assert.deepEqual(aBatchPayloads, []); + + bAnswerWith503 = true; + + // code under test + const oPromise = oModel.getMetaModel().requestObject( + "/TEAMS/TEAM_2_EMPLOYEES/EMPLOYEE_2_EQUIPMENTS/EQUIPMENT_2_PRODUCT/ID/$Type"); + + await resolveLater(undefined, /*iDelay*/10); // autoRespondAfter defaults to 10ms + + const sExpectedRequestLine + = "GET /sap/opu/odata4/IWBEP/TEA/default/iwbep/tea_busi_product/0001/$metadata"; + assert.strictEqual(aBatchPayloads.shift(), sExpectedRequestLine, + "cross-service reference $metadata failed at first try"); + assert.deepEqual(aBatchPayloads, []); + assert.strictEqual(iCallbackCount, 1); + + bAnswerWith503 = false; + fnResolveRetryAfter(); + + assert.strictEqual(await oPromise, "Edm.Int32"); + + assert.strictEqual(aBatchPayloads.shift(), sExpectedRequestLine, + "cross-service reference $metadata succeeded at second try"); + assert.deepEqual(aBatchPayloads, []); + assert.strictEqual(iCallbackCount, 1, "not called again"); + + TestUtils.onRequest(null); + }); + //********************************************************************************************* // Scenario: Table gets a binding context for which data was already loaded and then a refresh // is performed synchronously. diff --git a/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataModel.qunit.js b/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataModel.qunit.js index 2a2a71c603e3..ff91e709e12d 100644 --- a/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataModel.qunit.js +++ b/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataModel.qunit.js @@ -119,10 +119,10 @@ sap.ui.define([ .withExactArgs({}, false, true).returns({"sap-client" : "279"}); this.mock(Supportability).expects("isStatisticsEnabled") .withExactArgs().returns(bStatistics); - this.mock(_MetadataRequestor).expects("create") + const oExpectation = this.mock(_MetadataRequestor).expects("create") .withExactArgs({"Accept-Language" : "ab-CD"}, "4.0", undefined, bStatistics ? {"sap-client" : "279", "sap-statistics" : true} - : {"sap-client" : "279"}, undefined) + : {"sap-client" : "279"}, undefined, sinon.match.func) .returns(oMetadataRequestor); this.mock(ODataMetaModel.prototype).expects("fetchEntityContainer").withExactArgs(true); this.mock(ODataModel.prototype).expects("initializeSecurityToken").withExactArgs(); @@ -154,6 +154,8 @@ sap.ui.define([ assert.deepEqual(oModel.mPath2DataRequestedCount, {}); assert.deepEqual(oModel.mPath2DataReceivedError, {}); assert.strictEqual(oModel.fnRetryAfter, null); + assert.strictEqual(oModel.oRetryAfterPromise, null); + assert.strictEqual(oExpectation.args[0][5], oModel.oInterface.getOrCreateRetryAfterPromise); }); }); @@ -172,7 +174,7 @@ sap.ui.define([ "sap-client" : "279", "sap-context-token" : "20200716120000", "sap-language" : "EN" - }, undefined); + }, undefined, sinon.match.func); this.mock(_Requestor).expects("create") .withExactArgs(sServiceUrl, sinon.match.object, {"Accept-Language" : "ab-CD"}, {"sap-client" : "279", "sap-context-token" : "n/a"}, "4.0", undefined) @@ -246,7 +248,7 @@ sap.ui.define([ }); oMetadataRequestorCreateExpectation = this.mock(_MetadataRequestor).expects("create") .withExactArgs({"Accept-Language" : "ab-CD"}, sODataVersion, undefined, - sinon.match.object, undefined) + sinon.match.object, undefined, sinon.match.func) .returns({}); // code under test @@ -445,7 +447,7 @@ sap.ui.define([ this.mock(_MetadataRequestor).expects("create") .withExactArgs({"Accept-Language" : "ab-CD"}, "4.0", /*bIngnoreAnnotationsFromMetadata*/undefined, /*mQueryParams*/{}, - /*bWithCredentials*/bWithCredentials); + /*bWithCredentials*/bWithCredentials, sinon.match.func); this.mock(_Requestor).expects("create") .withExactArgs(sServiceUrl, { fetchEntityContainer : sinon.match.func, @@ -456,8 +458,8 @@ sap.ui.define([ getGroupProperty : sinon.match.func, getMessagesByPath : sinon.match.func, getOptimisticBatchEnabler : sinon.match.func, + getOrCreateRetryAfterPromise : sinon.match.func, getReporter : sinon.match.func, - getRetryAfterHandler : sinon.match.func, isIgnoreETag : sinon.match.func, onCreateGroup : sinon.match.func, onHttpResponse : sinon.match.func, @@ -484,15 +486,6 @@ sap.ui.define([ QUnit.test("Model creates _Requestor, sap-statistics=" + bStatistics, function (assert) { var oExpectedBind0, oExpectedBind1, - oExpectedBind2, - oExpectedBind3, - oExpectedBind4, - oExpectedBind5, - oExpectedBind6, - oExpectedBind7, - oExpectedBind8, - oExpectedBind9, - oExpectedBind10, oExpectedCreate = this.mock(_Requestor).expects("create"), oModel, oModelInterface, @@ -514,8 +507,8 @@ sap.ui.define([ getGroupProperty : "~fnGetGroupProperty~", getMessagesByPath : "~fnGetMessagesByPath~", getOptimisticBatchEnabler : "~fnGetOptimisticBatchEnabler~", + getOrCreateRetryAfterPromise : "~fnGetOrCreateRetryAfterPromise~", getReporter : "~fnGetReporter~", - getRetryAfterHandler : sinon.match.func, isIgnoreETag : sinon.match.func, onCreateGroup : sinon.match.func, onHttpResponse : sinon.match.func, @@ -534,24 +527,28 @@ sap.ui.define([ .returns("~fnFetchEntityContainer~"); oExpectedBind1 = this.mock(ODataMetaModel.prototype.fetchObject).expects("bind") .returns("~fnFetchMetadata~"); - oExpectedBind2 = this.mock(ODataModel.prototype.fireDataReceived).expects("bind") - .returns("~fnFireDataReceived~"); - oExpectedBind3 = this.mock(ODataModel.prototype.fireDataRequested).expects("bind") - .returns("~fnFireDataRequested~"); - oExpectedBind4 = this.mock(ODataModel.prototype.getGroupProperty).expects("bind") - .returns("~fnGetGroupProperty~"); - oExpectedBind5 = this.mock(ODataModel.prototype.getMessagesByPath).expects("bind") - .returns("~fnGetMessagesByPath~"); - oExpectedBind6 = this.mock(ODataModel.prototype.getOptimisticBatchEnabler).expects("bind") - .returns("~fnGetOptimisticBatchEnabler~"); - oExpectedBind7 = this.mock(ODataModel.prototype.getReporter).expects("bind") - .returns("~fnGetReporter~"); - oExpectedBind8 = this.mock(ODataModel.prototype.reportTransitionMessages).expects("bind") - .returns("~fnReportTransitionMessages~"); - oExpectedBind9 = this.mock(ODataModel.prototype.reportStateMessages).expects("bind") - .returns("~fnReportStateMessages~"); - oExpectedBind10 = this.mock(ODataModel.prototype.reportError).expects("bind") - .returns("~fnReportError~"); + const aExpectedBindOnModel = [ + this.mock(ODataModel.prototype.fireDataReceived).expects("bind") + .returns("~fnFireDataReceived~"), + this.mock(ODataModel.prototype.fireDataRequested).expects("bind") + .returns("~fnFireDataRequested~"), + this.mock(ODataModel.prototype.getGroupProperty).expects("bind") + .returns("~fnGetGroupProperty~"), + this.mock(ODataModel.prototype.getMessagesByPath).expects("bind") + .returns("~fnGetMessagesByPath~"), + this.mock(ODataModel.prototype.getOptimisticBatchEnabler).expects("bind") + .returns("~fnGetOptimisticBatchEnabler~"), + this.mock(ODataModel.prototype.getOrCreateRetryAfterPromise).expects("bind") + .returns("~fnGetOrCreateRetryAfterPromise~"), + this.mock(ODataModel.prototype.getReporter).expects("bind") + .returns("~fnGetReporter~"), + this.mock(ODataModel.prototype.reportTransitionMessages).expects("bind") + .returns("~fnReportTransitionMessages~"), + this.mock(ODataModel.prototype.reportStateMessages).expects("bind") + .returns("~fnReportStateMessages~"), + this.mock(ODataModel.prototype.reportError).expects("bind") + .returns("~fnReportError~") + ]; // code under test oModel = this.createModel("?sap-client=123", {}, true); @@ -560,15 +557,9 @@ sap.ui.define([ assert.strictEqual(oModel.oRequestor, oRequestor); assert.strictEqual(oExpectedBind0.firstCall.args[0], oModel.oMetaModel); assert.strictEqual(oExpectedBind1.firstCall.args[0], oModel.oMetaModel); - assert.strictEqual(oExpectedBind2.firstCall.args[0], oModel); - assert.strictEqual(oExpectedBind3.firstCall.args[0], oModel); - assert.strictEqual(oExpectedBind4.firstCall.args[0], oModel); - assert.strictEqual(oExpectedBind5.firstCall.args[0], oModel); - assert.strictEqual(oExpectedBind6.firstCall.args[0], oModel); - assert.strictEqual(oExpectedBind7.firstCall.args[0], oModel); - assert.strictEqual(oExpectedBind8.firstCall.args[0], oModel); - assert.strictEqual(oExpectedBind9.firstCall.args[0], oModel); - assert.strictEqual(oExpectedBind10.firstCall.args[0], oModel); + aExpectedBindOnModel.forEach((oExpectedBind) => { + assert.strictEqual(oExpectedBind.firstCall.args[0], oModel); + }); oModelInterface = oExpectedCreate.firstCall.args[1]; assert.strictEqual(oModelInterface, oModel.oInterface); @@ -633,9 +624,6 @@ sap.ui.define([ oModel.setRetryAfterHandler("~fnRetryAfter~"); assert.strictEqual(oModel.fnRetryAfter, "~fnRetryAfter~"); - - // code under test - assert.strictEqual(oModelInterface.getRetryAfterHandler(), "~fnRetryAfter~"); }); }); @@ -1233,6 +1221,7 @@ sap.ui.define([ assert.strictEqual(oModel.mHeaders, undefined); assert.strictEqual(oModel.mMetadataHeaders, undefined); + assert.strictEqual(oModel.oRetryAfterPromise, undefined); }); //********************************************************************************************* @@ -3582,6 +3571,83 @@ sap.ui.define([ }); }); + //********************************************************************************************* + QUnit.test("getOrCreateRetryAfterPromise: get", function (assert) { + const oModel = this.createModel(); + + oModel.oRetryAfterPromise = "~oRetryAfterPromise~"; + oModel.fnRetryAfter = mustBeMocked; // must not be called + + // code under test + assert.strictEqual(oModel.getOrCreateRetryAfterPromise("n/a"), "~oRetryAfterPromise~"); + }); + + //********************************************************************************************* + QUnit.test("getOrCreateRetryAfterPromise: create, resolves", async function (assert) { + const oModel = this.createModel(); + + // code under test + assert.strictEqual(oModel.getOrCreateRetryAfterPromise(), null, "initially"); + + oModel.fnRetryAfter = mustBeMocked; + + // code under test + assert.strictEqual(oModel.getOrCreateRetryAfterPromise(), null, "no error, no create"); + + const oRetryAfterPromise = Promise.resolve(); + this.mock(oModel).expects("fnRetryAfter").withExactArgs("~oRetryAfterError~") + .returns(oRetryAfterPromise); + + assert.strictEqual( + // code under test + oModel.getOrCreateRetryAfterPromise("~oRetryAfterError~"), + oRetryAfterPromise); + + // code under test + assert.strictEqual(oModel.getOrCreateRetryAfterPromise(), oRetryAfterPromise, "get"); + assert.strictEqual(oModel.getOrCreateRetryAfterPromise("n/a"), oRetryAfterPromise, + "no new promise"); + + await oRetryAfterPromise; + + // code under test + assert.strictEqual(oModel.getOrCreateRetryAfterPromise(), null, "cleaned up"); + }); + + //********************************************************************************************* +[false, true].forEach((bOwnError) => { + const sTitle = "getOrCreateRetryAfterPromise: create, rejects w/ own error = " + bOwnError; + + QUnit.test(sTitle, async function (assert) { + const oModel = this.createModel(); + + oModel.fnRetryAfter = mustBeMocked; + const oError = new Error("Some error message"); + const oRetryAfterPromise = Promise.reject(oError); + const oRetryAfterError = bOwnError ? "~oRetryAfterError~" : oError; + this.mock(oModel).expects("fnRetryAfter").withExactArgs(sinon.match.same(oRetryAfterError)) + .returns(oRetryAfterPromise); + this.mock(oModel).expects("reportError").exactly(bOwnError ? 0 : 1) + .withExactArgs("Some error message", sClassName, sinon.match.same(oError)); + + assert.strictEqual( + // code under test + oModel.getOrCreateRetryAfterPromise(oRetryAfterError), + oRetryAfterPromise); + + await oRetryAfterPromise.catch(() => {}); + + // code under test + assert.strictEqual(oModel.getOrCreateRetryAfterPromise(), null, "cleaned up"); + + if (bOwnError) { + assert.strictEqual(oError.$reported, true); + } else { + assert.notOk("$reported" in oError); + } + }); +}); + //********************************************************************************************* QUnit.test("getKeyPredicate, requestKeyPredicate", function (assert) { return TestUtils.checkGetAndRequest(this, this.createModel(), assert, "fetchKeyPredicate", diff --git a/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataModel.realOData.qunit.js b/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataModel.realOData.qunit.js index 80947bc25d04..8f71404694f1 100644 --- a/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataModel.realOData.qunit.js +++ b/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/ODataModel.realOData.qunit.js @@ -192,6 +192,7 @@ sap.ui.define([ oModelInterface = { fireSessionTimeout : function () {}, getGroupProperty : defaultGetGroupProperty, + getOrCreateRetryAfterPromise : function () {}, isIgnoreETag : function () {}, onCreateGroup : function () {}, onHttpResponse : function () {}, diff --git a/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/lib/_MetadataRequestor.qunit.js b/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/lib/_MetadataRequestor.qunit.js index 7ca20063da54..a4484c3c99d8 100644 --- a/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/lib/_MetadataRequestor.qunit.js +++ b/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/lib/_MetadataRequestor.qunit.js @@ -19,8 +19,13 @@ sap.ui.define([ : {source : "metadata.xml"}, "/sap/opu/odata4/IWBEP/TEA/default/IWBEP/TEA_BUSI/0001/metadata.json" : {source : "metadata.json"} + }, + fnGetOrCreateRetryAfterPromise = function () { + return null; }; + function mustBeMocked() { throw new Error("Must be mocked"); } + /** * Creates a mock for jQuery's XHR wrapper. * @@ -127,10 +132,9 @@ sap.ui.define([ } // code under test - oMetadataRequestor - = _MetadataRequestor.create(mHeaders, sODataVersion, - /*bIgnoreAnnotationsFromMetadata*/false, mQueryParams, - /*bWithCredentials*/true); + oMetadataRequestor = _MetadataRequestor.create(mHeaders, sODataVersion, + /*bIgnoreAnnotationsFromMetadata*/false, mQueryParams, + /*bWithCredentials*/true, fnGetOrCreateRetryAfterPromise); // code under test return oMetadataRequestor.read(sUrl).then(function (oResult) { @@ -150,7 +154,7 @@ sap.ui.define([ }); //********************************************************************************************* - QUnit.test("read: success with Date, ETag and Last-Modified", function (assert) { + QUnit.test("read: success with Date, ETag and Last-Modified", async function (assert) { var sDate = "Tue, 18 Apr 2017 14:40:29 GMT", sETag = 'W/"19700101000000.0000000"', sLastModified = "Fri, 07 Apr 2017 11:21:50 GMT", @@ -167,10 +171,28 @@ sap.ui.define([ }, oExpectedXml = {}, mHeaders = {}, - oMetadataRequestor = _MetadataRequestor.create(mHeaders, "4.0"), + fnResolve, + oRetryAfterPromise = new Promise(function (resolve) { + fnResolve = resolve; + }), + oMetadataRequestor = _MetadataRequestor.create(mHeaders, "4.0", undefined, null, false, + function () { + return oRetryAfterPromise; + }), sUrl = "/~/"; - this.mock(jQuery).expects("ajax") + const oJQueryMock = this.mock(jQuery); + oJQueryMock.expects("ajax").never(); + + // code under test + const oPromise = oMetadataRequestor.read(sUrl); + + await new Promise(function (resolve) { + setTimeout(resolve, 5); + }); + + fnResolve(); + oJQueryMock.expects("ajax") .withExactArgs(sUrl, { headers : sinon.match.same(mHeaders), method : "GET" @@ -179,9 +201,26 @@ sap.ui.define([ .withExactArgs(sinon.match.same(oExpectedXml), sUrl, undefined) .returns(oExpectedJson); + const oResult = await oPromise; + + assert.deepEqual(oResult, oExpectedResult); + }); + + //********************************************************************************************* + QUnit.test("read: oRetryAfterPromise rejects", function (assert) { + const oMetadataRequestor = _MetadataRequestor.create({}, "4.0", undefined, null, false, + function () { + return Promise.reject("~oError~"); + }); + this.mock(jQuery).expects("ajax").never(); + // Note: if oRetryAfterPromise rejects, "app" is broken and we don't care about other + // features of read() + // code under test - return oMetadataRequestor.read(sUrl).then(function (oResult) { - assert.deepEqual(oResult, oExpectedResult); + return oMetadataRequestor.read("n/a").then(function () { + assert.ok(false); + }, function (oError) { + assert.strictEqual(oError, "~oError~"); }); }); @@ -212,7 +251,8 @@ sap.ui.define([ oHelperMock.expects("buildQuery") .withExactArgs(sinon.match.same(mQueryParams)) .returns(sQuery1); - oMetadataRequestor = _MetadataRequestor.create(mHeaders, "4.0", true, mQueryParams); + oMetadataRequestor = _MetadataRequestor.create(mHeaders, "4.0", true, mQueryParams, + false, fnGetOrCreateRetryAfterPromise); oJQueryMock.expects("ajax") .withExactArgs(sAnnotationUrl, { headers : sinon.match.same(mHeaders), @@ -267,7 +307,8 @@ sap.ui.define([ sLastModified = "Fri, 07 Apr 2017 11:21:50 GMT", oExpectedXml = {}, mHeaders = {}, - oMetadataRequestor = _MetadataRequestor.create(mHeaders, "4.0"), + oMetadataRequestor = _MetadataRequestor.create(mHeaders, "4.0", undefined, null, false, + fnGetOrCreateRetryAfterPromise), sUrl = "/~/"; oJQueryMock.expects("ajax") @@ -335,31 +376,96 @@ sap.ui.define([ }); //********************************************************************************************* - QUnit.test("read: failure", function (assert) { - var jqXHR = {}, - oExpectedError = new Error("404 Not Found"), - oMetadataRequestor = _MetadataRequestor.create({}, "4.0"), - sUrl = "/foo/$metadata"; - +[404, 503].forEach((iStatus) => { + QUnit.test("read: failure, status=" + iStatus, function (assert) { + const oObject = { + getOrCreateRetryAfterPromise : mustBeMocked + }; + const oObjectMock = this.mock(oObject); + oObjectMock.expects("getOrCreateRetryAfterPromise").withExactArgs().returns(null); // get + const oMetadataRequestor = _MetadataRequestor.create({}, "4.0", false, null, false, + oObject.getOrCreateRetryAfterPromise); + const jqXHR = { + status : iStatus, + getResponseHeader : mustBeMocked + }; + this.mock(jqXHR).expects("getResponseHeader").exactly(iStatus === 503 ? 1 : 0) + .withExactArgs("Retry-After").returns(""); // "" is a very near miss :-) this.mock(jQuery).expects("ajax") .returns(createMock(jqXHR, true)); // true = fail + const oExpectedError = new Error("Intentionally failed"); this.mock(_Helper).expects("createError") .withExactArgs(sinon.match.same(jqXHR), "Could not load metadata") .returns(oExpectedError); this.oLogMock.expects("error") - .withExactArgs("GET " + sUrl, oExpectedError.message, + .withExactArgs("GET " + "/foo/$metadata", oExpectedError.message, "sap.ui.model.odata.v4.lib._MetadataRequestor"); - return oMetadataRequestor.read(sUrl).then(function () { + return oMetadataRequestor.read("/foo/$metadata").then(function () { assert.ok(false); }, function (oError) { assert.strictEqual(oError, oExpectedError); }); }); +}); + + //********************************************************************************************* +[false, true].forEach((bRepeat) => { + QUnit.test("read: 503 failure, repeat: " + bRepeat, function (assert) { + const oObject = { + getOrCreateRetryAfterPromise : mustBeMocked + }; + const oObjectMock = this.mock(oObject); + oObjectMock.expects("getOrCreateRetryAfterPromise").withExactArgs().returns(null); // get + const oMetadataRequestor = _MetadataRequestor.create({}, "4.0", false, null, false, + oObject.getOrCreateRetryAfterPromise); + const oJQueryMock = this.mock(jQuery); + const jqXHR = { + status : 503, + getResponseHeader : mustBeMocked + }; + oJQueryMock.expects("ajax").withExactArgs("/foo/$metadata", sinon.match.object) + .returns(createMock(jqXHR, true)); // true = fail + const oRetryAfterError = new Error("DB migration in progress"); + this.mock(_Helper).expects("createError") + .withExactArgs(sinon.match.same(jqXHR), "Could not load metadata") + .returns(oRetryAfterError); + this.mock(jqXHR).expects("getResponseHeader").withExactArgs("Retry-After").returns("42"); + oObjectMock.expects("getOrCreateRetryAfterPromise") + .withExactArgs(sinon.match.same(oRetryAfterError)).returns("truthy"); // create + const oRetryAfterPromise = bRepeat ? Promise.resolve() : Promise.reject("~oError~"); + oObjectMock.expects("getOrCreateRetryAfterPromise") + .withExactArgs().returns(oRetryAfterPromise); // get + const oExpectedJson = {}; + if (bRepeat) { + oJQueryMock.expects("ajax").withExactArgs("/foo/$metadata", sinon.match.object) + .returns(createMock("~oData~")); + this.mock(_V4MetadataConverter.prototype).expects("convertXMLMetadata") + .withExactArgs("~oData~", "/foo/$metadata", false) + .returns(oExpectedJson); + } else { + this.mock(_V4MetadataConverter.prototype).expects("convertXMLMetadata").never(); + // Note: if oRetryAfterPromise rejects, "app" is broken and we don't care about other + // features of read() + } + + oRetryAfterPromise.catch(() => {}); // avoid random(?) "global failure" + + // code under test + return oMetadataRequestor.read("/foo/$metadata").then(function (oResult) { + assert.ok(bRepeat); + assert.strictEqual(oResult, oExpectedJson); + }, function (oError) { + assert.ok(!bRepeat); + assert.strictEqual(oError, "~oError~"); + }); + }); +}); //********************************************************************************************* QUnit.test("read: test service", function (assert) { - var oMetadataRequestor = _MetadataRequestor.create({}, "4.0"); + var oMetadataRequestor = _MetadataRequestor.create({}, "4.0", false, null, false, + fnGetOrCreateRetryAfterPromise); return Promise.all([ oMetadataRequestor.read( @@ -372,7 +478,8 @@ sap.ui.define([ //********************************************************************************************* QUnit.test("read: test service; ignoreAnnotationsFromMetadata", function (assert) { - var oMetadataRequestor = _MetadataRequestor.create({}, "4.0", true, {}); + var oMetadataRequestor = _MetadataRequestor.create({}, "4.0", true, {}, false, + fnGetOrCreateRetryAfterPromise); return Promise.all([ oMetadataRequestor.read( diff --git a/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/lib/_Requestor.qunit.js b/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/lib/_Requestor.qunit.js index ee8ccde3e9db..5a655443e7d0 100644 --- a/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/lib/_Requestor.qunit.js +++ b/src/sap.ui.core/test/sap/ui/core/qunit/odata/v4/lib/_Requestor.qunit.js @@ -22,8 +22,8 @@ sap.ui.define([ fireSessionTimeout : function () {}, getGroupProperty : defaultGetGroupProperty, getOptimisticBatchEnabler : mustBeMocked, + getOrCreateRetryAfterPromise : function () {}, // called too often for mustBeMocked getReporter : function () {}, - getRetryAfterHandler : function () {}, isIgnoreETag : function () {}, onCreateGroup : function () {}, onHttpResponse : mustBeMocked, @@ -261,7 +261,6 @@ sap.ui.define([ assert.deepEqual(oRequestor.aLockedGroupLocks, []); assert.strictEqual(oRequestor.oModelInterface, oModelInterface); assert.strictEqual(oRequestor.sQueryParams, "?~"); - assert.strictEqual(oRequestor.oRetryAfterPromise, null); assert.deepEqual(oRequestor.mRunningChangeRequests, {}); assert.strictEqual(oRequestor.oSecurityTokenPromise, null); assert.strictEqual(oRequestor.iSessionTimer, 0); @@ -914,17 +913,15 @@ sap.ui.define([ }); //********************************************************************************************* - // Integrative test simulating 503 "Retry-After" handling: resolve or reject with original or - // own error + // Integrative test simulating 503 "Retry-After" handling: resolve or reject // 1) Send 2 parallel requests (both supposed to be answered with 503) // 1a) 1st 503 error response creates "Retry-After" promise // 1b) 2nd response reuses the promise // 2) 3rd follow up request reuses also the promise, no request sent // 3a) Resolving promise repeats all 3 requests - // 3b) Rejecting promise rejects all 3 requests with the original error or an own error -[false, /*reject with own error*/null, true].forEach((bResolve) => { + // 3b) Rejecting promise rejects all 3 requests +[false, true].forEach((bResolve) => { QUnit.test(`sendRequest: 503, "Retry-After" handling, bResolve=${bResolve}`, function (assert) { - const bOwnError = bResolve === null; const oRequestor = _Requestor.create("/Service/", oModelInterface); let fnReject; let fnResolve; @@ -933,44 +930,20 @@ sap.ui.define([ fnReject = reject; }); const oRetryAfterError = {message : "DB migration in progress"}; - function fnRetryAfter(oError) { - assert.strictEqual(oError, oRetryAfterError); - return oRetryAfterPromise; - } - - const oOwnError = {}; - function checkError(oError) { - assert.notOk(bResolve); - assert.strictEqual(oError, bOwnError ? oOwnError : oRetryAfterError); - if (bOwnError) { - assert.strictEqual(oError.$reported, true); - } else { - assert.notOk("$reported" in oError); - } - } - - function checkSuccess() { - assert.ok(bResolve); - } - const oHelperMock = this.mock(_Helper); - const o503jqXHR = { - getResponseHeader() {}, - status : 503 - }; const oJQueryMock = this.mock(jQuery); - const o503jqXHRMock = this.mock(o503jqXHR); const oModelInterfaceMock = this.mock(oModelInterface); + oModelInterfaceMock.expects("getOrCreateRetryAfterPromise").twice().withExactArgs() + .returns(null); let oSendPromise3; - this.mock(oRequestor.oModelInterface).expects("reportError") - .exactly(bResolve === false ? 1 : 0) - .withExactArgs(oRetryAfterError.message, sClassName, - sinon.match.same(oRetryAfterError)); oJQueryMock.expects("ajax").withArgs("/Service/First").callsFake(() => { const jqXHR = new jQuery.Deferred(); setTimeout(async () => { - assert.strictEqual(oRequestor.oRetryAfterPromise, null); - + const o503jqXHR = { + getResponseHeader() {}, + status : 503 + }; + const o503jqXHRMock = this.mock(o503jqXHR); o503jqXHRMock.expects("getResponseHeader") .withExactArgs("SAP-ContextId") .returns("n/a"); @@ -980,13 +953,13 @@ sap.ui.define([ o503jqXHRMock.expects("getResponseHeader") .withExactArgs("Retry-After") .returns("42"); - oModelInterfaceMock.expects("getRetryAfterHandler") - .withExactArgs() - .twice() - .returns(fnRetryAfter); oHelperMock.expects("createError") .withExactArgs(sinon.match.same(o503jqXHR), "") .returns(oRetryAfterError); + oModelInterfaceMock.expects("getOrCreateRetryAfterPromise") + .withExactArgs(sinon.match.same(oRetryAfterError)).returns("truthy"); // create + oModelInterfaceMock.expects("getOrCreateRetryAfterPromise").withExactArgs() + .returns(oRetryAfterPromise); // get // (1a) jqXHR.reject(o503jqXHR); @@ -997,9 +970,10 @@ sap.ui.define([ // continue regardless of error } - assert.strictEqual(oRequestor.oRetryAfterPromise, oRetryAfterPromise); // register follow up request for oRetryAfterPromise and NOT oSecurityTokenPromise oRequestor.oSecurityTokenPromise = {}; + oModelInterfaceMock.expects("getOrCreateRetryAfterPromise").withExactArgs() + .returns(oRetryAfterPromise); // get // code under test (2) oSendPromise3 = oRequestor.sendRequest("POST", "Third"); @@ -1011,6 +985,11 @@ sap.ui.define([ oJQueryMock.expects("ajax").withArgs("/Service/Second").callsFake(() => { const jqXHR = new jQuery.Deferred(); setTimeout(async () => { + const o503jqXHR = { + getResponseHeader() {}, + status : 503 + }; + const o503jqXHRMock = this.mock(o503jqXHR); o503jqXHRMock.expects("getResponseHeader") .withExactArgs("SAP-ContextId") .returns("n/a"); @@ -1020,6 +999,13 @@ sap.ui.define([ o503jqXHRMock.expects("getResponseHeader") .withExactArgs("Retry-After") .returns("42"); + oHelperMock.expects("createError") + .withExactArgs(sinon.match.same(o503jqXHR), "") + .returns("~oRetryAfterError~"); + oModelInterfaceMock.expects("getOrCreateRetryAfterPromise") + .withExactArgs("~oRetryAfterError~").returns("n/a"); // no create needed + oModelInterfaceMock.expects("getOrCreateRetryAfterPromise").withExactArgs() + .returns(oRetryAfterPromise); // get same as other parallel request // (1b) jqXHR.reject(o503jqXHR); @@ -1030,15 +1016,10 @@ sap.ui.define([ // continue regardless of error } - assert.strictEqual(oRequestor.oRetryAfterPromise, oRetryAfterPromise); - if (bResolve) { oJQueryMock.expects("ajax") .withArgs("/Service/First") .callsFake(() => { - assert.strictEqual(oRequestor.oRetryAfterPromise, null); - // check that promise is reset only once in case of resolving - oRequestor.oRetryAfterPromise = "~otherPromise~"; return createMock(assert, {/*oPayload*/}, "OK"); }); oJQueryMock.expects("ajax") @@ -1059,7 +1040,7 @@ sap.ui.define([ fnResolve(); } else { // code under test (3b) - fnReject(bOwnError ? oOwnError : oRetryAfterError); + fnReject(oRetryAfterError); } }, 0); @@ -1070,24 +1051,19 @@ sap.ui.define([ const oSendPromise1 = oRequestor.sendRequest("GET", "First"); const oSendPromise2 = oRequestor.sendRequest("GET", "Second"); + const checkError = (oError) => { + assert.notOk(bResolve); + assert.strictEqual(oError, oRetryAfterError); + }; + const checkSuccess = () => { + assert.ok(bResolve); + }; + return Promise.all([ - oSendPromise1.then(checkSuccess, (oError) => { - assert.strictEqual(oRequestor.oRetryAfterPromise, null); - // check that promise is reset only once in case of rejecting - oRequestor.oRetryAfterPromise = "~otherPromise~"; - checkError(oError); - }), - oSendPromise2.then(checkSuccess, checkError), - oRetryAfterPromise.then(checkSuccess, function (oError) { - assert.notOk(bResolve); - assert.strictEqual(oError, bOwnError ? oOwnError : oRetryAfterError); - // must not check $reported before caught and set in productive code - }) + oSendPromise1.then(checkSuccess, checkError), + oSendPromise2.then(checkSuccess, checkError) ]).then(function () { return oSendPromise3.then(checkSuccess, checkError); - }).then(function () { - assert.strictEqual(oRequestor.oRetryAfterPromise, "~otherPromise~"); - return oRetryAfterPromise.then(checkSuccess, checkError); }); }); }); @@ -1111,11 +1087,15 @@ sap.ui.define([ o503jqXHRMock.expects("getResponseHeader") .withExactArgs("Retry-After") .returns(sRetryAfter); - this.mock(oModelInterface).expects("getRetryAfterHandler") - .withExactArgs() + const oHelperMock = this.mock(_Helper); + oHelperMock.expects("createError").exactly(sRetryAfter ? 1 : 0) + .withExactArgs(sinon.match.same(o503jqXHR), "") + .returns("n/a"); + this.mock(oModelInterface).expects("getOrCreateRetryAfterPromise") .exactly(sRetryAfter ? 1 : 0) + .withExactArgs("n/a") .returns(null); - this.mock(_Helper).expects("createError") + oHelperMock.expects("createError") .withExactArgs(sinon.match.same(o503jqXHR), "Communication error", "/Service/Foo", undefined) .returns("~oError~");