diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index 24dbf092..4a65994b 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -1300,3 +1300,40 @@ Validation: Follow-ups: - Future non-GET relay adoption should explicitly opt in/out per route using `relayPolicy` and include targeted negative-path tests. + +--- + +### CL-035: TASK22242 apply relayPolicy overrides across broader endpoint cluster + +date: 2026-03-24 +author: Cline +scope: `pages/api/endpoint/{getaccounts_api,getemailaccountcheck_api,getpreferredlanguage_api,getpersonalaccount_api,getlogin_api,getbasicsearch_api,getbasicsearchpaged_api,getadvancedsearch_api,getadvancedsearchpaged_api}.js`, `tests/phase21/endpoint-handler-contract.test.cjs` +type: change +rationale: Expand practical adoption of per-endpoint relay policy tuning so high-traffic account/login/search handlers explicitly declare timeout/retry posture rather than relying only on global defaults. +impact: Better operational control and predictable retry behavior per endpoint cluster, with no API contract changes. +status: completed + +Summary: + +- Added explicit `relayPolicy` usage to a broader endpoint set: + - account/login: `getaccounts`, `getemailaccountcheck`, `getpreferredlanguage`, `getpersonalaccount`, `getlogin` + - search: `getbasicsearch`, `getbasicsearchpaged`, `getadvancedsearch`, `getadvancedsearchpaged` +- Applied conservative policy profiles by flow: + - login endpoint (`getlogin`): no retries (`maxRetries: 0`) and tighter timeout + - account lookup endpoints: low retry posture (`maxRetries: 1`) + - search endpoints: bounded retry posture (`maxRetries: 2`) for transient resilience +- Kept method explicit as `GET` in policy for clarity and future-proofing. +- Extended phase21 endpoint tests with relayPolicy propagation assertions: + - `getaccounts` relayPolicy pass-through + - `getlogin` strict relayPolicy pass-through + - `getbasicsearchpaged` relayPolicy pass-through + +Validation: + +- `node tests/phase21/endpoint-handler-contract.test.cjs` -> pass (155/155) +- `node tests/phase21/relay-forwarding-hardening.test.cjs` -> pass (10/10) +- `npm run lint` -> warnings only (pre-existing `react-hooks/exhaustive-deps`; no new lint errors) + +Follow-ups: + +- Optional next slice: apply relayPolicy declarations to remaining relayGet endpoints in coherent batches (portal module/documents/DNS groups) and standardize policy presets in one shared constants module. diff --git a/pages/api/endpoint/getaccounts_api.js b/pages/api/endpoint/getaccounts_api.js index f1316370..525f0dcd 100644 --- a/pages/api/endpoint/getaccounts_api.js +++ b/pages/api/endpoint/getaccounts_api.js @@ -34,9 +34,18 @@ export default async function ApiProxy(req, res) { emailAddress + "'&$count=true&$select=emailaddress1, contactid"; + const relayPolicy = { + method: "GET", + timeoutMs: 6000, + maxRetries: 1, + retryBaseDelayMs: 100, + retryMaxDelayMs: 500 + }; + return relayGet({ queryUrl, res, + relayPolicy, errorResponse: { status: 400, code: "ACCOUNTS_FETCH_FAILED", diff --git a/pages/api/endpoint/getadvancedsearch_api.js b/pages/api/endpoint/getadvancedsearch_api.js index e90592b5..fd0c505e 100644 --- a/pages/api/endpoint/getadvancedsearch_api.js +++ b/pages/api/endpoint/getadvancedsearch_api.js @@ -103,10 +103,19 @@ export default async function ApiProxy(req, res) { queryString + " pinswg_appealcasetype ne null and pinswg_publishtoweb eq true&$orderby=createdon desc&$count=true"; + const relayPolicy = { + method: "GET", + timeoutMs: 9000, + maxRetries: 2, + retryBaseDelayMs: 150, + retryMaxDelayMs: 1000 + }; + return relayGet({ queryUrl, res, requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken), + relayPolicy, transformData: async (data, accessToken) => { if (Object.prototype.hasOwnProperty.call(data, "@odata.nextLink")) { const dataStr = JSON.stringify(data["@odata.nextLink"]); diff --git a/pages/api/endpoint/getadvancedsearchpaged_api.js b/pages/api/endpoint/getadvancedsearchpaged_api.js index f62a21ef..116b6fb0 100644 --- a/pages/api/endpoint/getadvancedsearchpaged_api.js +++ b/pages/api/endpoint/getadvancedsearchpaged_api.js @@ -173,11 +173,20 @@ export default async function ApiProxy(req, res) { : ""); } + const relayPolicy = { + method: "GET", + timeoutMs: 9000, + maxRetries: 2, + retryBaseDelayMs: 150, + retryMaxDelayMs: 1000 + }; + return relayGet({ queryUrl, res, requestOptionsBuilder: (accessToken) => azureHeadersPagedCustom(accessToken, showNumberOfRecords), + relayPolicy, transformData: (data) => { let dataStr; _.has(data, "@odata.nextLink") === true && diff --git a/pages/api/endpoint/getbasicsearch_api.js b/pages/api/endpoint/getbasicsearch_api.js index 61726fc7..54bd9720 100644 --- a/pages/api/endpoint/getbasicsearch_api.js +++ b/pages/api/endpoint/getbasicsearch_api.js @@ -45,10 +45,19 @@ export default async function ApiProxy(req, res) { : "") + "and pinswg_publishtoweb eq true&$orderby=createdon desc&$count=true"; + const relayPolicy = { + method: "GET", + timeoutMs: 8000, + maxRetries: 2, + retryBaseDelayMs: 150, + retryMaxDelayMs: 800 + }; + return relayGet({ queryUrl, res, requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken), + relayPolicy, transformData: (data) => { if (Object.prototype.hasOwnProperty.call(data, "@odata.nextLink")) { const dataStr = JSON.stringify(data["@odata.nextLink"]); diff --git a/pages/api/endpoint/getbasicsearchpaged_api.js b/pages/api/endpoint/getbasicsearchpaged_api.js index 0da70548..ff79ec36 100644 --- a/pages/api/endpoint/getbasicsearchpaged_api.js +++ b/pages/api/endpoint/getbasicsearchpaged_api.js @@ -111,11 +111,20 @@ export default async function ApiProxy(req, res) { ? "&$skiptoken=" + '' : ""); + const relayPolicy = { + method: "GET", + timeoutMs: 9000, + maxRetries: 2, + retryBaseDelayMs: 150, + retryMaxDelayMs: 1000 + }; + return relayGet({ queryUrl, res, requestOptionsBuilder: (accessToken) => azureHeadersPagedCustom(accessToken, showNumberOfRecords), + relayPolicy, transformData: (data) => { if (_.has(data, "@odata.nextLink") === true) { const dataStr = JSON.stringify(data["@odata.nextLink"]); diff --git a/pages/api/endpoint/getemailaccountcheck_api.js b/pages/api/endpoint/getemailaccountcheck_api.js index 1751d5eb..a1a75700 100644 --- a/pages/api/endpoint/getemailaccountcheck_api.js +++ b/pages/api/endpoint/getemailaccountcheck_api.js @@ -34,9 +34,18 @@ export default async function ApiProxy(req, res) { emailAddress + "'&$count=true&$select=emailaddress1, contactid"; + const relayPolicy = { + method: "GET", + timeoutMs: 6000, + maxRetries: 1, + retryBaseDelayMs: 100, + retryMaxDelayMs: 500 + }; + return relayGet({ queryUrl, res, + relayPolicy, errorResponse: { status: 400, code: "EMAIL_ACCOUNT_CHECK_FAILED", diff --git a/pages/api/endpoint/getlogin_api.js b/pages/api/endpoint/getlogin_api.js index cf828194..4a97954b 100644 --- a/pages/api/endpoint/getlogin_api.js +++ b/pages/api/endpoint/getlogin_api.js @@ -52,10 +52,19 @@ export default async function ApiProxy(req, res) { pwd + "'&$count=true&$select=emailaddress1,contactid,pinswg_custom_password,yomifullname,firstname,lastname"; + const relayPolicy = { + method: "GET", + timeoutMs: 5000, + maxRetries: 0, + retryBaseDelayMs: 0, + retryMaxDelayMs: 0 + }; + return relayGet({ queryUrl, res, requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken), + relayPolicy, errorResponse: { status: 400, code: "LOGIN_FETCH_FAILED", diff --git a/pages/api/endpoint/getpersonalaccount_api.js b/pages/api/endpoint/getpersonalaccount_api.js index 56039c90..e6573756 100644 --- a/pages/api/endpoint/getpersonalaccount_api.js +++ b/pages/api/endpoint/getpersonalaccount_api.js @@ -36,9 +36,18 @@ export default async function ApiProxy(req, res) { contactid + ")?$select=firstname, lastname, emailaddress1, telephone1, company, address1_line1,address1_line2,address1_city, address1_county,address1_postalcode,pinswg_typeofinvolvement,pinswg_contact_associatedlpa,pinswg_preferredlanguage&$count=true"; + const relayPolicy = { + method: "GET", + timeoutMs: 6000, + maxRetries: 1, + retryBaseDelayMs: 100, + retryMaxDelayMs: 500 + }; + return relayGet({ queryUrl, res, + relayPolicy, errorResponse: { status: 400, code: "PERSONAL_ACCOUNT_FETCH_FAILED", diff --git a/pages/api/endpoint/getpreferredlanguage_api.js b/pages/api/endpoint/getpreferredlanguage_api.js index 0051dc77..ea650fcf 100644 --- a/pages/api/endpoint/getpreferredlanguage_api.js +++ b/pages/api/endpoint/getpreferredlanguage_api.js @@ -39,9 +39,18 @@ export default async function ApiProxy(req, res) { escapeODataString(emailAddress) + "'&$count=true&$select=pinswg_preferredlanguage,contactid"; + const relayPolicy = { + method: "GET", + timeoutMs: 6000, + maxRetries: 1, + retryBaseDelayMs: 100, + retryMaxDelayMs: 500 + }; + return relayGet({ queryUrl, res, + relayPolicy, errorResponse: { status: 400, code: "PREFERRED_LANGUAGE_FETCH_FAILED", diff --git a/tests/phase21/endpoint-handler-contract.test.cjs b/tests/phase21/endpoint-handler-contract.test.cjs index 23dd939d..c321d3c1 100644 --- a/tests/phase21/endpoint-handler-contract.test.cjs +++ b/tests/phase21/endpoint-handler-contract.test.cjs @@ -54,6 +54,32 @@ test("getaccounts catch path returns ACCOUNTS_FETCH_FAILED", async () => { assert.strictEqual(res.state.jsonBody.error.code, "ACCOUNTS_FETCH_FAILED"); }); +test("getaccounts passes relayPolicy overrides to relayGet", async () => { + let capturedRelayPolicy = null; + + const mod = loadModule("pages/api/endpoint/getaccounts_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + relayGet: async ({ relayPolicy, res }) => { + capturedRelayPolicy = relayPolicy; + return respondSuccessMock(res, { value: [] }); + } + }); + + const req = { query: { emailAddress: "user@test.local" } }; + const res = createRes(); + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), { + method: "GET", + timeoutMs: 6000, + maxRetries: 1, + retryBaseDelayMs: 100, + retryMaxDelayMs: 500 + }); +}); + test("getemailaccountcheck returns EMAIL_ADDRESS_REQUIRED when emailAddress missing", async () => { const mod = loadModule("pages/api/endpoint/getemailaccountcheck_api.js", { respondError: respondErrorMock, @@ -270,6 +296,33 @@ test("getlogin catch path returns LOGIN_FETCH_FAILED", async () => { assert.strictEqual(res.state.jsonBody.error.code, "LOGIN_FETCH_FAILED"); }); +test("getlogin passes strict relayPolicy overrides to relayGet", async () => { + let capturedRelayPolicy = null; + + const mod = loadModule("pages/api/endpoint/getlogin_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + relayGet: async ({ relayPolicy, res }) => { + capturedRelayPolicy = relayPolicy; + return respondSuccessMock(res, { value: [] }); + }, + azureHeadersPaged: () => ({}) + }); + + const req = { query: { emailAddress: "user@test.local", pwd: "abc123" } }; + const res = createRes(); + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), { + method: "GET", + timeoutMs: 5000, + maxRetries: 0, + retryBaseDelayMs: 0, + retryMaxDelayMs: 0 + }); +}); + test("getportallogin returns HASH_REQUIRED when hash missing", async () => { const mod = loadModule("pages/api/endpoint/getportallogin_api.js", { respondError: respondErrorMock, @@ -1976,6 +2029,41 @@ test("getbasicsearchpaged catch path returns BASIC_SEARCH_PAGED_FETCH_FAILED", a ); }); +test("getbasicsearchpaged passes relayPolicy overrides to relayGet", async () => { + let capturedRelayPolicy = null; + + const mod = loadModule("pages/api/endpoint/getbasicsearchpaged_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + relayGet: async ({ relayPolicy, res }) => { + capturedRelayPolicy = relayPolicy; + return respondSuccessMock(res, { value: [] }); + }, + azureHeadersPagedCustom: () => ({}), + _: { has: () => false } + }); + + const req = { + query: { + searchString: "cas", + orderby: "createdon", + fieldSort: "asc", + showNumberOfRecords: "10" + } + }; + const res = createRes(); + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), { + method: "GET", + timeoutMs: 9000, + maxRetries: 2, + retryBaseDelayMs: 150, + retryMaxDelayMs: 1000 + }); +}); + test("getbasicsearch_by_lparref returns LPA_REF_REQUIRED when lpaRef missing", async () => { const mod = loadModule( "pages/api/endpoint/getbasicsearch_by_lparref_api.js",