Merged PR 2205: addin timeouts and retries for stability

Related work items: #22242
This commit is contained in:
Robert Bond
2026-03-24 18:19:01 +00:00
24 changed files with 1087 additions and 17 deletions
+222
View File
@@ -1222,3 +1222,225 @@ Validation:
Follow-ups:
- Optional next iteration: add a PR template block in Azure DevOps mirroring the runbook governance gate checklist.
---
### CL-033: TASK22242 relay telemetry enrichment (lifecycle events + correlation fields)
date: 2026-03-24
author: Cline
scope: `pages/api/middleware/relayForwarding.js`, `tests/phase21/relay-forwarding-hardening.test.cjs`
type: change
rationale: Add richer relay observability so operations can correlate retries and outcomes per request and track latency/status patterns without changing endpoint contracts.
impact: Improves operational diagnostics and trend analysis for relay traffic while preserving existing API behavior.
status: completed
Summary:
- Enriched relay middleware telemetry with request lifecycle events:
- `relay_request_started`
- `relay_request_retrying`
- `relay_request_succeeded`
- `relay_request_failed`
- Added shared telemetry fields for correlation and analysis:
- `relayRequestId` (per request correlation id)
- `attemptsMade`, `retryCountUsed`, `remainingRetries`
- `elapsedMs`
- `statusClass` (`2xx/4xx/5xx` style buckets)
- resolved runtime knobs included at start event
- Kept existing retry policy and endpoint response contracts unchanged.
- Expanded phase21 relay hardening tests to assert telemetry behavior:
- started/retrying/succeeded event presence
- failed event telemetry fields
- stable `relayRequestId` across lifecycle events for one request
Validation:
- `node tests/phase21/relay-forwarding-hardening.test.cjs` -> pass (7/7)
- `node tests/phase21/endpoint-handler-contract.test.cjs` -> pass (152/152)
- `npm run lint` -> warnings only (pre-existing `react-hooks/exhaustive-deps`; no new lint errors)
Follow-ups:
- Optional next step: map these lifecycle fields into central dashboards/alerts (retry rate, status-class distribution, p95 elapsedMs).
---
### CL-034: TASK22242 per-endpoint relay overrides + idempotency-aware retry gating
date: 2026-03-24
author: Cline
scope: `pages/api/middleware/relayForwarding.js`, `tests/phase21/relay-forwarding-hardening.test.cjs`
type: change
rationale: Deliver the next functional relay enhancement by enabling route-level retry tuning while adding safe-by-default retry gating for non-idempotent methods.
impact: Improves control and safety of relay retries without breaking existing endpoint contracts.
status: completed
Summary:
- Added **relay policy override support** (`relayPolicy`) to shared relay helpers (`relayGet`, `relayGetData`, `forwardGetData`):
- per-call override of `timeoutMs`, `maxRetries`, `retryBaseDelayMs`, `retryMaxDelayMs`
- optional method override via `relayPolicy.method`
- Added **idempotency-aware retry gating scaffolding**:
- retries allowed by default only for idempotent methods (`GET`, `HEAD`, `OPTIONS`)
- non-idempotent retry behavior controlled by:
- env flag `RELAY_ALLOW_NON_IDEMPOTENT_RETRIES` (default false)
- per-call override `relayPolicy.allowNonIdempotentRetries`
- Extended relay telemetry fields to include method and non-idempotent policy posture in start/failure/retry events.
- Preserved existing route behavior:
- existing GET endpoint flows continue to use retries per configured bounds
- no endpoint response contract changes
Validation:
- `node tests/phase21/relay-forwarding-hardening.test.cjs` -> pass (10/10)
- `node tests/phase21/endpoint-handler-contract.test.cjs` -> pass (152/152)
- `npm run lint` -> warnings only (pre-existing `react-hooks/exhaustive-deps`; no new lint errors)
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.
---
### CL-036: TASK22242 portal-facing relayPolicy parity (login + module endpoints)
date: 2026-03-24
author: Cline
scope: `pages/api/endpoint/{getportallogin_api,getportalloginproxy_api,getportalmoduledetails_api,getportalmoduledetailsproxy_api}.js`, `tests/phase21/endpoint-handler-contract.test.cjs`
type: change
rationale: Complete the next practical relay policy rollout slice by bringing portal-facing login/module endpoints onto explicit per-endpoint timeout/retry posture.
impact: Improves predictability and operational tuning for portal-facing relay GET flows without changing API contracts.
status: completed
Summary:
- Added explicit `relayPolicy` for four portal-facing endpoints:
- `getportallogin_api` -> strict/no-retry profile (`maxRetries: 0`, tighter timeout)
- `getportalloginproxy_api` -> low-retry account lookup profile (`maxRetries: 1`)
- `getportalmoduledetails_api` -> bounded read profile (`maxRetries: 2`)
- `getportalmoduledetailsproxy_api` -> bounded read profile (`maxRetries: 2`)
- Kept method explicit (`GET`) in each endpoint policy object.
- Extended phase21 endpoint tests with relayPolicy propagation assertions for all four endpoints.
Validation:
- `node tests/phase21/endpoint-handler-contract.test.cjs` -> pass (159/159)
- `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: extract shared relay policy presets into constants to reduce duplication and enforce profile consistency across remaining relayGet endpoints.
---
### CL-037: TASK22242 portal/my-cases relayPolicy parity (my portal + representations)
date: 2026-03-24
author: Cline
scope: `pages/api/endpoint/{getmycases_api,getmyrepresentations_api,getwatchedcases_api,getawaitingsubmission_api,getrepresentations_api}.js`, `tests/phase21/endpoint-handler-contract.test.cjs`
type: change
rationale: Continue the branch-by-branch relay policy rollout by applying explicit policy posture to core my-portal retrieval endpoints.
impact: Improves consistency and operational predictability of relay behavior for portal case/representation listing flows without changing endpoint contracts.
status: completed
Summary:
- Added explicit `relayPolicy` declarations to:
- `getmycases_api`
- `getmyrepresentations_api`
- `getwatchedcases_api`
- `getawaitingsubmission_api`
- `getrepresentations_api`
- Applied bounded read profile across the batch:
- `method: "GET"`
- `timeoutMs: 8000`
- `maxRetries: 2`
- `retryBaseDelayMs: 150`
- `retryMaxDelayMs: 800`
- Extended phase21 endpoint contract tests with relayPolicy pass-through assertions for each endpoint.
Validation:
- `node tests/phase21/endpoint-handler-contract.test.cjs` -> pass (164/164)
- `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: extract shared relay policy presets into a constants module and reference them from all relayGet endpoints to reduce duplication.
---
### CL-038: TASK22242 P2-S3 closure slice — shared relay policy presets extraction
date: 2026-03-24
author: Cline
scope: `pages/api/middleware/relayPolicyPresets.js`, `pages/api/endpoint/{getaccounts_api,getemailaccountcheck_api,getpreferredlanguage_api,getpersonalaccount_api,getlogin_api,getportallogin_api,getportalloginproxy_api,getbasicsearch_api,getbasicsearchpaged_api,getadvancedsearch_api,getadvancedsearchpaged_api,getportalmoduledetails_api,getportalmoduledetailsproxy_api,getmycases_api,getmyrepresentations_api,getawaitingsubmission_api,getrepresentations_api,getwatchedcases_api}.js`, `tests/phase21/_shared.cjs`
type: change
rationale: Complete the planned P2-S3 final maintainability slice by centralizing repeated relay timeout/retry policy objects into shared presets used consistently across all targeted relayGet endpoints.
impact: Eliminates duplicated policy literals, reduces drift risk, and preserves endpoint contracts/behavior by reusing equivalent policy values.
status: completed
Summary:
- Added new middleware constants module:
- `pages/api/middleware/relayPolicyPresets.js`
- `RELAY_POLICY_STRICT_LOGIN`
- `RELAY_POLICY_LOOKUP`
- `RELAY_POLICY_BOUNDED_READ`
- `RELAY_POLICY_SEARCH_PAGED`
- Refactored 18 relayGet endpoints to import and use shared presets instead of inline `relayPolicy` object literals:
- lookup profile: `getaccounts`, `getemailaccountcheck`, `getpreferredlanguage`, `getpersonalaccount`, `getportalloginproxy`
- strict login profile: `getlogin`, `getportallogin`
- bounded read profile: `getbasicsearch`, `getportalmoduledetails`, `getportalmoduledetailsproxy`, `getmycases`, `getmyrepresentations`, `getawaitingsubmission`, `getrepresentations`, `getwatchedcases`
- search paged profile: `getbasicsearchpaged`, `getadvancedsearch`, `getadvancedsearchpaged`
- Updated phase21 VM test harness (`tests/phase21/_shared.cjs`) to inject preset constants so endpoint contract tests continue to execute with import-stripped modules.
Validation:
- `node tests/phase21/endpoint-handler-contract.test.cjs` -> pass (164/164)
- `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:
- P2-S3 planned slices are now complete; no further mandatory relay policy rollout slices remain for this stream.
+4
View File
@@ -17,6 +17,7 @@
*/
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_LOOKUP } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const emailAddress = req.query.emailAddress;
@@ -34,9 +35,12 @@ export default async function ApiProxy(req, res) {
emailAddress +
"'&$count=true&$select=emailaddress1, contactid";
const relayPolicy = RELAY_POLICY_LOOKUP;
return relayGet({
queryUrl,
res,
relayPolicy,
errorResponse: {
status: 400,
code: "ACCOUNTS_FETCH_FAILED",
@@ -25,6 +25,7 @@ import { azureHeadersPaged } from "../../../actions/core/headers";
import { consoleLogger } from "../../../actions/core/logger";
import { respondError, respondSuccess } from "../middleware/apiResponse";
import { relayGet, relayGetData } from "../middleware/relayForwarding";
import { RELAY_POLICY_SEARCH_PAGED } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const encodedSearchString = req.query.searchstring;
@@ -103,10 +104,13 @@ export default async function ApiProxy(req, res) {
queryString +
" pinswg_appealcasetype ne null and pinswg_publishtoweb eq true&$orderby=createdon desc&$count=true";
const relayPolicy = RELAY_POLICY_SEARCH_PAGED;
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"]);
@@ -51,6 +51,7 @@ import _ from "lodash";
import { azureHeadersPagedCustom } from "../../../actions/core/headers";
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_SEARCH_PAGED } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const rawSearchString = req.query.searchstring;
@@ -173,11 +174,14 @@ export default async function ApiProxy(req, res) {
: "");
}
const relayPolicy = RELAY_POLICY_SEARCH_PAGED;
return relayGet({
queryUrl,
res,
requestOptionsBuilder: (accessToken) =>
azureHeadersPagedCustom(accessToken, showNumberOfRecords),
relayPolicy,
transformData: (data) => {
let dataStr;
_.has(data, "@odata.nextLink") === true &&
@@ -19,6 +19,7 @@
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const loggedInUserId = req.query.loggedInUserId;
@@ -39,9 +40,12 @@ export default async function ApiProxy(req, res) {
loggedInUserId +
" and servicestage eq 1 and pinswg_appealcasetype ne null&$orderby=createdon desc&$count=true";
const relayPolicy = RELAY_POLICY_BOUNDED_READ;
return relayGet({
queryUrl,
res,
relayPolicy,
transformData: (data) => {
data.value.forEach(function (element) {
element.pinswg_title = element.title;
+4
View File
@@ -18,6 +18,7 @@
import { azureHeadersPaged } from "../../../actions/core/headers";
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const searchString = req.query.searchString;
@@ -45,10 +46,13 @@ export default async function ApiProxy(req, res) {
: "") +
"and pinswg_publishtoweb eq true&$orderby=createdon desc&$count=true";
const relayPolicy = RELAY_POLICY_BOUNDED_READ;
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"]);
@@ -46,6 +46,7 @@ import _ from "lodash";
import { azureHeadersPagedCustom } from "../../../actions/core/headers";
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_SEARCH_PAGED } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
let searchString = req.query.searchString;
@@ -111,11 +112,14 @@ export default async function ApiProxy(req, res) {
? "&$skiptoken=" + '<cookie pagenumber="' + pageNumber + '" />'
: "");
const relayPolicy = RELAY_POLICY_SEARCH_PAGED;
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"]);
@@ -17,6 +17,7 @@
*/
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_LOOKUP } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const emailAddress = req.query.emailAddress;
@@ -34,9 +35,12 @@ export default async function ApiProxy(req, res) {
emailAddress +
"'&$count=true&$select=emailaddress1, contactid";
const relayPolicy = RELAY_POLICY_LOOKUP;
return relayGet({
queryUrl,
res,
relayPolicy,
errorResponse: {
status: 400,
code: "EMAIL_ACCOUNT_CHECK_FAILED",
+4
View File
@@ -24,6 +24,7 @@
import { azureHeadersPaged } from "../../../actions/core/headers";
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_STRICT_LOGIN } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const emailAddress = req.query.emailAddress;
@@ -52,10 +53,13 @@ export default async function ApiProxy(req, res) {
pwd +
"'&$count=true&$select=emailaddress1,contactid,pinswg_custom_password,yomifullname,firstname,lastname";
const relayPolicy = RELAY_POLICY_STRICT_LOGIN;
return relayGet({
queryUrl,
res,
requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken),
relayPolicy,
errorResponse: {
status: 400,
code: "LOGIN_FETCH_FAILED",
+4
View File
@@ -19,6 +19,7 @@
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const loggedInUserId = req.query.loggedInUserId;
@@ -39,9 +40,12 @@ export default async function ApiProxy(req, res) {
loggedInUserId +
" and pinswg_appealcasetype ne null&$orderby=createdon desc&$count=true";
const relayPolicy = RELAY_POLICY_BOUNDED_READ;
return relayGet({
queryUrl,
res,
relayPolicy,
transformData: (data) => {
data.value.forEach(function (element) {
element.pinswg_title = element.title;
@@ -19,6 +19,7 @@
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const loggedInUserId = req.query.loggedInUserId;
@@ -39,9 +40,12 @@ export default async function ApiProxy(req, res) {
loggedInUserId +
"&$count=true&$orderby=createdon desc";
const relayPolicy = RELAY_POLICY_BOUNDED_READ;
return relayGet({
queryUrl,
res,
relayPolicy,
errorResponse: {
status: 400,
code: "MY_REPRESENTATIONS_FETCH_FAILED",
@@ -19,6 +19,7 @@
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_LOOKUP } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const contactid = req.query.contactid;
@@ -36,9 +37,12 @@ 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 = RELAY_POLICY_LOOKUP;
return relayGet({
queryUrl,
res,
relayPolicy,
errorResponse: {
status: 400,
code: "PERSONAL_ACCOUNT_FETCH_FAILED",
+4
View File
@@ -20,6 +20,7 @@ import { azureHeadersPaged } from "../../../actions/core/headers";
import { hashAPIPath } from "../../../actions/core/hash";
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_STRICT_LOGIN } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const emailAddress = req.query.emailAddress;
@@ -65,10 +66,13 @@ export default async function ApiProxy(req, res) {
emailAddress +
"' and statuscode eq 1&$count=true&$select=emailaddress1,contactid,yomifullname,firstname,lastname";
const relayPolicy = RELAY_POLICY_STRICT_LOGIN;
return relayGet({
queryUrl,
res,
requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken),
relayPolicy,
errorResponse: {
status: 400,
code: "PORTAL_LOGIN_FETCH_FAILED",
@@ -19,6 +19,7 @@
import { azureHeadersPaged } from "../../../actions/core/headers";
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_LOOKUP } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const emailAddress = req.query.emailAddress;
@@ -36,10 +37,13 @@ export default async function ApiProxy(req, res) {
emailAddress +
"'&$count=true&$select=emailaddress1,contactid,yomifullname,firstname,lastname";
const relayPolicy = RELAY_POLICY_LOOKUP;
return relayGet({
queryUrl,
res,
requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken),
relayPolicy,
errorResponse: {
status: 400,
code: "PORTAL_LOGIN_PROXY_FETCH_FAILED",
@@ -26,6 +26,7 @@
import { getSelectQuery } from "../../../actions/selectQueryTypes";
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const appealType = req.query.appealType;
@@ -60,9 +61,12 @@ export default async function ApiProxy(req, res) {
queryUrl = queryUrl + getSelectQuery(appealType);
const relayPolicy = RELAY_POLICY_BOUNDED_READ;
return relayGet({
queryUrl,
res,
relayPolicy,
errorResponse: {
status: 400,
code: "PORTAL_MODULE_DETAILS_FETCH_FAILED",
@@ -26,6 +26,7 @@
import { getSelectQuery } from "../../../actions/selectQueryTypes";
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const appealType = req.query.appealType;
@@ -60,9 +61,12 @@ export default async function ApiProxy(req, res) {
queryUrl = queryUrl + getSelectQuery(appealType);
const relayPolicy = RELAY_POLICY_BOUNDED_READ;
return relayGet({
queryUrl,
res,
relayPolicy,
errorResponse: {
status: 400,
code: "PORTAL_MODULE_DETAILS_PROXY_FETCH_FAILED",
@@ -22,6 +22,7 @@ import {
} from "../../../actions/core/guards";
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_LOOKUP } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const emailAddress = sanitizeString(req.query.emailAddress);
@@ -39,9 +40,12 @@ export default async function ApiProxy(req, res) {
escapeODataString(emailAddress) +
"'&$count=true&$select=pinswg_preferredlanguage,contactid";
const relayPolicy = RELAY_POLICY_LOOKUP;
return relayGet({
queryUrl,
res,
relayPolicy,
errorResponse: {
status: 400,
code: "PREFERRED_LANGUAGE_FETCH_FAILED",
@@ -19,6 +19,7 @@
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const incidentID = req.query.incidentID;
@@ -36,9 +37,12 @@ export default async function ApiProxy(req, res) {
incidentID +
" and pinswg_publishtoweb eq true&$count=true&$orderby=createdon desc";
const relayPolicy = RELAY_POLICY_BOUNDED_READ;
return relayGet({
queryUrl,
res,
relayPolicy,
errorResponse: {
status: 400,
code: "REPRESENTATIONS_FETCH_FAILED",
@@ -19,6 +19,7 @@
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets";
export default async function ApiProxy(req, res) {
const loggedInUserId = req.query.loggedInUserId;
@@ -39,9 +40,12 @@ export default async function ApiProxy(req, res) {
loggedInUserId +
"&$select=pinswg_emailnotifications,modifiedon,pinswg_appealcasetype,pinswg_watchlistid,_pinswg_watchedcase_value,statuscode,pinswg_representationsubmitted,pinswg_representationtype&$count=true&$orderby=createdon desc&$expand=pinswg_WatchedCase($select=pinswg_AssociatedLPA)";
const relayPolicy = RELAY_POLICY_BOUNDED_READ;
return relayGet({
queryUrl,
res,
relayPolicy,
transformData: (data) => {
data.value.forEach(function (element) {
element.ticketnumber = element.pinswg_WatchedCase?.ticketnumber;
+130 -15
View File
@@ -13,6 +13,7 @@ const DEFAULT_TIMEOUT_MS = 8000;
const DEFAULT_MAX_RETRIES = 2;
const DEFAULT_RETRY_BASE_DELAY_MS = 200;
const DEFAULT_RETRY_MAX_DELAY_MS = 1200;
const DEFAULT_ALLOW_NON_IDEMPOTENT_RETRIES = false;
const MAX_TIMEOUT_MS = 30000;
const MAX_RETRIES = 4;
const MAX_RETRY_DELAY_MS = 5000;
@@ -29,11 +30,24 @@ const RETRYABLE_ERROR_CODES = new Set([
"EPIPE"
]);
const IDEMPOTENT_RETRY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const parsePositiveInt = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
};
const parseBoolean = (value, fallback) => {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (normalized === "true") return true;
if (normalized === "false") return false;
}
return fallback;
};
const clamp = (value, min, max) => {
return Math.min(max, Math.max(min, value));
};
@@ -65,6 +79,24 @@ const resolveNumericOverride = ({
return clamp(Math.floor(overrideValue), lowerBound, max);
};
const resolveBooleanOverride = ({ overrideValue, fallbackValue }) => {
if (typeof overrideValue === "boolean") return overrideValue;
return fallbackValue;
};
const resolveRelayMethod = (value) => {
if (typeof value !== "string" || value.trim().length === 0) {
return "GET";
}
return value.trim().toUpperCase();
};
const canRetryForMethod = ({ method, allowNonIdempotentRetries }) => {
if (allowNonIdempotentRetries) return true;
return IDEMPOTENT_RETRY_METHODS.has(method);
};
const getRelayConfig = () => {
return {
timeoutMs: sanitizeNumberConfig({
@@ -93,7 +125,11 @@ const getRelayConfig = () => {
min: 0,
max: MAX_RETRY_DELAY_MS,
allowZero: true
})
}),
allowNonIdempotentRetries: parseBoolean(
process.env.RELAY_ALLOW_NON_IDEMPOTENT_RETRIES,
DEFAULT_ALLOW_NON_IDEMPOTENT_RETRIES
)
};
};
@@ -109,8 +145,28 @@ const structuredRelayLog = (event, payload) => {
console.info(event, redactSensitive(payload));
};
const shouldRetryRelayError = ({ error, attempt, maxRetries }) => {
const buildRelayRequestId = () => {
return `relay_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
};
const getElapsedMs = (startedAtMs) => {
return Math.max(0, Date.now() - startedAtMs);
};
const getStatusClass = (status) => {
if (typeof status !== "number") return "none";
return `${Math.floor(status / 100)}xx`;
};
const shouldRetryRelayError = ({
error,
attempt,
maxRetries,
method,
allowNonIdempotentRetries
}) => {
if (attempt >= maxRetries) return false;
if (!canRetryForMethod({ method, allowNonIdempotentRetries })) return false;
const status = error?.response?.status;
if (NON_RETRYABLE_STATUS_CODES.has(status)) return false;
@@ -126,12 +182,14 @@ export const relayGet = async ({
res,
errorResponse,
transformData,
requestOptionsBuilder
requestOptionsBuilder,
relayPolicy
}) => {
try {
const { data, accessToken } = await relayGetData({
queryUrl,
requestOptionsBuilder
requestOptionsBuilder,
relayPolicy
});
return respondSuccess(
@@ -151,12 +209,14 @@ export const relayGet = async ({
export const relayGetData = async ({
queryUrl,
requestOptionsBuilder,
accessToken
accessToken,
relayPolicy
}) => {
return forwardGetData({
queryUrl,
requestOptionsBuilder,
accessToken
accessToken,
relayPolicy
});
};
@@ -169,19 +229,26 @@ export const forwardGetData = async ({
timeoutMs,
maxRetries,
retryBaseDelayMs,
retryMaxDelayMs
retryMaxDelayMs,
method,
relayPolicy
}) => {
const relayConfig = getRelayConfig();
const relayPolicyConfig = relayPolicy || {};
const resolvedMethod = resolveRelayMethod(
relayPolicyConfig.method || method
);
const resolvedTimeoutMs = resolveNumericOverride({
overrideValue: timeoutMs,
overrideValue: relayPolicyConfig.timeoutMs ?? timeoutMs,
fallbackValue: relayConfig.timeoutMs,
min: 100,
max: MAX_TIMEOUT_MS
});
const resolvedMaxRetries = resolveNumericOverride({
overrideValue: maxRetries,
overrideValue: relayPolicyConfig.maxRetries ?? maxRetries,
fallbackValue: relayConfig.maxRetries,
min: 0,
max: MAX_RETRIES,
@@ -189,7 +256,7 @@ export const forwardGetData = async ({
});
const resolvedRetryBaseDelayMs = resolveNumericOverride({
overrideValue: retryBaseDelayMs,
overrideValue: relayPolicyConfig.retryBaseDelayMs ?? retryBaseDelayMs,
fallbackValue: relayConfig.retryBaseDelayMs,
min: 0,
max: MAX_RETRY_DELAY_MS,
@@ -197,13 +264,20 @@ export const forwardGetData = async ({
});
const resolvedRetryMaxDelayMs = resolveNumericOverride({
overrideValue: retryMaxDelayMs,
overrideValue: relayPolicyConfig.retryMaxDelayMs ?? retryMaxDelayMs,
fallbackValue: relayConfig.retryMaxDelayMs,
min: 0,
max: MAX_RETRY_DELAY_MS,
allowZero: true
});
const resolvedAllowNonIdempotentRetries = resolveBooleanOverride({
overrideValue:
relayPolicyConfig.allowNonIdempotentRetries ??
relayPolicyConfig.allowRetriesForNonIdempotent,
fallbackValue: relayConfig.allowNonIdempotentRetries
});
const tokenAccessToken =
typeof accessToken === "string" && accessToken.length > 0
? accessToken
@@ -226,11 +300,37 @@ export const forwardGetData = async ({
: resolvedTimeoutMs
};
const relayRequestId = buildRelayRequestId();
const startedAtMs = Date.now();
structuredRelayLog("relay_request_started", {
relayRequestId,
queryUrl,
baseUrl: resolvedBaseUrl,
method: resolvedMethod,
timeoutMs: resolvedTimeoutMs,
maxRetries: resolvedMaxRetries,
retryBaseDelayMs: resolvedRetryBaseDelayMs,
retryMaxDelayMs: resolvedRetryMaxDelayMs,
allowNonIdempotentRetries: resolvedAllowNonIdempotentRetries
});
let lastError;
for (let attempt = 0; attempt <= resolvedMaxRetries; attempt += 1) {
try {
const { data } = await axios.get(finalUrl, axiosOptions);
const { data, status } = await axios.get(finalUrl, axiosOptions);
structuredRelayLog("relay_request_succeeded", {
relayRequestId,
queryUrl,
baseUrl: resolvedBaseUrl,
status,
statusClass: getStatusClass(status),
attemptsMade: attempt + 1,
retryCountUsed: attempt,
elapsedMs: getElapsedMs(startedAtMs)
});
return {
data,
@@ -242,19 +342,27 @@ export const forwardGetData = async ({
const retryEligible = shouldRetryRelayError({
error,
attempt,
maxRetries: resolvedMaxRetries
maxRetries: resolvedMaxRetries,
method: resolvedMethod,
allowNonIdempotentRetries: resolvedAllowNonIdempotentRetries
});
if (!retryEligible) {
error.__relayAlreadyLogged = true;
structuredRelayLog("relay_request_failed", {
relayRequestId,
queryUrl,
baseUrl: resolvedBaseUrl,
method: resolvedMethod,
attempt: attempt + 1,
maxAttempts: resolvedMaxRetries + 1,
attemptsMade: attempt + 1,
retryCountUsed: attempt,
status: error?.response?.status,
statusClass: getStatusClass(error?.response?.status),
code: error?.code,
message: error?.message
message: error?.message,
elapsedMs: getElapsedMs(startedAtMs)
});
throw error;
}
@@ -266,14 +374,21 @@ export const forwardGetData = async ({
});
structuredRelayLog("relay_request_retrying", {
relayRequestId,
queryUrl,
baseUrl: resolvedBaseUrl,
method: resolvedMethod,
attempt: attempt + 1,
maxAttempts: resolvedMaxRetries + 1,
attemptsMade: attempt + 1,
retryCountUsed: attempt,
remainingRetries: resolvedMaxRetries - attempt,
delayMs,
status: error?.response?.status,
statusClass: getStatusClass(error?.response?.status),
code: error?.code,
message: error?.message
message: error?.message,
elapsedMs: getElapsedMs(startedAtMs)
});
await wait(delayMs);
@@ -0,0 +1,31 @@
export const RELAY_POLICY_STRICT_LOGIN = {
method: "GET",
timeoutMs: 5000,
maxRetries: 0,
retryBaseDelayMs: 0,
retryMaxDelayMs: 0
};
export const RELAY_POLICY_LOOKUP = {
method: "GET",
timeoutMs: 6000,
maxRetries: 1,
retryBaseDelayMs: 100,
retryMaxDelayMs: 500
};
export const RELAY_POLICY_BOUNDED_READ = {
method: "GET",
timeoutMs: 8000,
maxRetries: 2,
retryBaseDelayMs: 150,
retryMaxDelayMs: 800
};
export const RELAY_POLICY_SEARCH_PAGED = {
method: "GET",
timeoutMs: 9000,
maxRetries: 2,
retryBaseDelayMs: 150,
retryMaxDelayMs: 1000
};
+28
View File
@@ -55,6 +55,34 @@ const loadModule = (relativePath, injected = {}) => {
error: () => {}
},
relayGet: defaultRelayGet,
RELAY_POLICY_STRICT_LOGIN: {
method: "GET",
timeoutMs: 5000,
maxRetries: 0,
retryBaseDelayMs: 0,
retryMaxDelayMs: 0
},
RELAY_POLICY_LOOKUP: {
method: "GET",
timeoutMs: 6000,
maxRetries: 1,
retryBaseDelayMs: 100,
retryMaxDelayMs: 500
},
RELAY_POLICY_BOUNDED_READ: {
method: "GET",
timeoutMs: 8000,
maxRetries: 2,
retryBaseDelayMs: 150,
retryMaxDelayMs: 800
},
RELAY_POLICY_SEARCH_PAGED: {
method: "GET",
timeoutMs: 9000,
maxRetries: 2,
retryBaseDelayMs: 150,
retryMaxDelayMs: 1000
},
...injected
};
@@ -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,
@@ -336,6 +389,39 @@ test("getportallogin catch path returns PORTAL_LOGIN_FETCH_FAILED", async () =>
);
});
test("getportallogin passes strict relayPolicy overrides to relayGet", async () => {
let capturedRelayPolicy = null;
const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async ({ relayPolicy, res }) => {
capturedRelayPolicy = relayPolicy;
return respondSuccessMock(res, { value: [] });
},
hashAPIPath: (input) =>
input && input.startsWith("/api/endpoint/getportallogin_api")
? "&hash=expected"
: "&hash=relay",
azureHeadersPaged: () => ({})
});
const req = {
query: { emailAddress: "user@test.local", hash: "expected" }
};
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 accepts encoded email hash variant", async () => {
const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
respondError: respondErrorMock,
@@ -448,6 +534,33 @@ test("getportalloginproxy catch path returns PORTAL_LOGIN_PROXY_FETCH_FAILED", a
);
});
test("getportalloginproxy passes relayPolicy overrides to relayGet", async () => {
let capturedRelayPolicy = null;
const mod = loadModule("pages/api/endpoint/getportalloginproxy_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async ({ relayPolicy, res }) => {
capturedRelayPolicy = relayPolicy;
return respondSuccessMock(res, { value: [] });
},
azureHeadersPaged: () => ({})
});
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("getpersonalaccount returns CONTACT_ID_REQUIRED when contactid missing", async () => {
const mod = loadModule("pages/api/endpoint/getpersonalaccount_api.js", {
respondError: respondErrorMock,
@@ -1612,6 +1725,32 @@ test("getmycases catch path returns MY_CASES_FETCH_FAILED", async () => {
assert.strictEqual(res.state.jsonBody.error.code, "MY_CASES_FETCH_FAILED");
});
test("getmycases passes relayPolicy overrides to relayGet", async () => {
let capturedRelayPolicy = null;
const mod = loadModule("pages/api/endpoint/getmycases_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async ({ relayPolicy, res }) => {
capturedRelayPolicy = relayPolicy;
return respondSuccessMock(res, { value: [] });
}
});
const req = { query: { loggedInUserId: "c1" } };
const res = createRes();
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
method: "GET",
timeoutMs: 8000,
maxRetries: 2,
retryBaseDelayMs: 150,
retryMaxDelayMs: 800
});
});
test("getmyrepresentations returns LOGGED_IN_USER_ID_REQUIRED when loggedInUserId missing", async () => {
const mod = loadModule("pages/api/endpoint/getmyrepresentations_api.js", {
respondError: respondErrorMock,
@@ -1663,6 +1802,32 @@ test("getmyrepresentations catch path returns MY_REPRESENTATIONS_FETCH_FAILED",
);
});
test("getmyrepresentations passes relayPolicy overrides to relayGet", async () => {
let capturedRelayPolicy = null;
const mod = loadModule("pages/api/endpoint/getmyrepresentations_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async ({ relayPolicy, res }) => {
capturedRelayPolicy = relayPolicy;
return respondSuccessMock(res, { value: [] });
}
});
const req = { query: { loggedInUserId: "c1" } };
const res = createRes();
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
method: "GET",
timeoutMs: 8000,
maxRetries: 2,
retryBaseDelayMs: 150,
retryMaxDelayMs: 800
});
});
test("getwatchedcases returns LOGGED_IN_USER_ID_REQUIRED when loggedInUserId missing", async () => {
const mod = loadModule("pages/api/endpoint/getwatchedcases_api.js", {
respondError: respondErrorMock,
@@ -1714,6 +1879,32 @@ test("getwatchedcases catch path returns WATCHED_CASES_FETCH_FAILED", async () =
);
});
test("getwatchedcases passes relayPolicy overrides to relayGet", async () => {
let capturedRelayPolicy = null;
const mod = loadModule("pages/api/endpoint/getwatchedcases_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async ({ relayPolicy, res }) => {
capturedRelayPolicy = relayPolicy;
return respondSuccessMock(res, { value: [] });
}
});
const req = { query: { loggedInUserId: "c1" } };
const res = createRes();
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
method: "GET",
timeoutMs: 8000,
maxRetries: 2,
retryBaseDelayMs: 150,
retryMaxDelayMs: 800
});
});
test("getawaitingsubmission returns LOGGED_IN_USER_ID_REQUIRED when loggedInUserId missing", async () => {
const mod = loadModule("pages/api/endpoint/getawaitingsubmission_api.js", {
respondError: respondErrorMock,
@@ -1765,6 +1956,32 @@ test("getawaitingsubmission catch path returns AWAITING_SUBMISSION_FETCH_FAILED"
);
});
test("getawaitingsubmission passes relayPolicy overrides to relayGet", async () => {
let capturedRelayPolicy = null;
const mod = loadModule("pages/api/endpoint/getawaitingsubmission_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async ({ relayPolicy, res }) => {
capturedRelayPolicy = relayPolicy;
return respondSuccessMock(res, { value: [] });
}
});
const req = { query: { loggedInUserId: "c1" } };
const res = createRes();
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
method: "GET",
timeoutMs: 8000,
maxRetries: 2,
retryBaseDelayMs: 150,
retryMaxDelayMs: 800
});
});
test("getbasicsearchdetails returns APPEAL_TYPE_NAME_REQUIRED when appealTypeName missing", async () => {
const mod = loadModule("pages/api/endpoint/getbasicsearchdetails_api.js", {
respondError: respondErrorMock,
@@ -1976,6 +2193,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",
@@ -2322,6 +2574,38 @@ test("getportalmoduledetails catch path returns PORTAL_MODULE_DETAILS_FETCH_FAIL
);
});
test("getportalmoduledetails passes relayPolicy overrides to relayGet", async () => {
let capturedRelayPolicy = null;
const mod = loadModule("pages/api/endpoint/getportalmoduledetails_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async ({ relayPolicy, res }) => {
capturedRelayPolicy = relayPolicy;
return respondSuccessMock(res, { value: [] });
},
getSelectQuery: () => "&$select=pinswg_name"
});
const req = {
query: {
appealType: "pinswg_planningappeals78s",
caseReference: "CAS-1"
}
};
const res = createRes();
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
method: "GET",
timeoutMs: 8000,
maxRetries: 2,
retryBaseDelayMs: 150,
retryMaxDelayMs: 800
});
});
test("getportalmoduledetailsproxy returns APPEAL_TYPE_REQUIRED when appealType missing", async () => {
const mod = loadModule(
"pages/api/endpoint/getportalmoduledetailsproxy_api.js",
@@ -2383,6 +2667,41 @@ test("getportalmoduledetailsproxy catch path returns PORTAL_MODULE_DETAILS_PROXY
);
});
test("getportalmoduledetailsproxy passes relayPolicy overrides to relayGet", async () => {
let capturedRelayPolicy = null;
const mod = loadModule(
"pages/api/endpoint/getportalmoduledetailsproxy_api.js",
{
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async ({ relayPolicy, res }) => {
capturedRelayPolicy = relayPolicy;
return respondSuccessMock(res, { value: [] });
},
getSelectQuery: () => "&$select=pinswg_name"
}
);
const req = {
query: {
appealType: "pinswg_planningappeals78s",
caseReference: "CAS-1"
}
};
const res = createRes();
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
method: "GET",
timeoutMs: 8000,
maxRetries: 2,
retryBaseDelayMs: 150,
retryMaxDelayMs: 800
});
});
test("getmylpacases returns LPA_ID_REQUIRED when lpaid missing", async () => {
const mod = loadModule("pages/api/endpoint/getmylpacases_api.js", {
respondError: respondErrorMock,
@@ -3394,6 +3713,32 @@ test("getrepresentations catch path returns REPRESENTATIONS_FETCH_FAILED", async
);
});
test("getrepresentations passes relayPolicy overrides to relayGet", async () => {
let capturedRelayPolicy = null;
const mod = loadModule("pages/api/endpoint/getrepresentations_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async ({ relayPolicy, res }) => {
capturedRelayPolicy = relayPolicy;
return respondSuccessMock(res, { value: [] });
}
});
const req = { query: { incidentID: "i1" } };
const res = createRes();
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
method: "GET",
timeoutMs: 8000,
maxRetries: 2,
retryBaseDelayMs: 150,
retryMaxDelayMs: 800
});
});
test("getdnscoords catch path returns DNS_COORDS_FETCH_FAILED", async () => {
const mod = loadModule("pages/api/endpoint/getdnscoords_api.js", {
respondError: respondErrorMock,
@@ -43,6 +43,7 @@ const test = (name, fn) => tests.push({ name, fn });
test("forwardGetData retries retryable HTTP status and then succeeds", async () => {
let callCount = 0;
const infoLogs = [];
const mod = loadRelayForwardingModule({
axios: {
@@ -61,7 +62,13 @@ test("forwardGetData retries retryable HTTP status and then succeeds", async ()
hashAPIPath: () => "&hash=abc",
azureHeaders: () => ({ headers: { Authorization: "Bearer token" } }),
redactSensitive: (value) => value,
consoleLogger: () => {}
consoleLogger: () => {},
console: {
log: () => {},
info: (...args) => infoLogs.push(args),
warn: () => {},
error: () => {}
}
});
const result = await mod.forwardGetData({
@@ -74,10 +81,39 @@ test("forwardGetData retries retryable HTTP status and then succeeds", async ()
assert.strictEqual(callCount, 2);
assert.deepStrictEqual(result.data, { ok: true });
assert.strictEqual(result.accessToken, "token");
const eventNames = infoLogs.map((entry) => entry[0]);
assert.ok(eventNames.includes("relay_request_started"));
assert.ok(eventNames.includes("relay_request_retrying"));
assert.ok(eventNames.includes("relay_request_succeeded"));
const retryEvent = infoLogs.find(
(entry) => entry[0] === "relay_request_retrying"
);
assert.ok(retryEvent);
const retryPayload = retryEvent[1];
assert.strictEqual(retryPayload.statusClass, "5xx");
assert.strictEqual(retryPayload.attemptsMade, 1);
assert.strictEqual(retryPayload.retryCountUsed, 0);
assert.strictEqual(retryPayload.remainingRetries, 1);
assert.ok(typeof retryPayload.relayRequestId === "string");
const successEvent = infoLogs.find(
(entry) => entry[0] === "relay_request_succeeded"
);
assert.ok(successEvent);
const successPayload = successEvent[1];
assert.strictEqual(successPayload.statusClass, "none");
assert.strictEqual(successPayload.attemptsMade, 2);
assert.strictEqual(successPayload.retryCountUsed, 1);
assert.ok(typeof successPayload.elapsedMs === "number");
});
test("forwardGetData does not retry non-retryable HTTP status", async () => {
let callCount = 0;
const infoLogs = [];
const mod = loadRelayForwardingModule({
axios: {
@@ -92,7 +128,13 @@ test("forwardGetData does not retry non-retryable HTTP status", async () => {
hashAPIPath: () => "&hash=abc",
azureHeaders: () => ({ headers: {} }),
redactSensitive: (value) => value,
consoleLogger: () => {}
consoleLogger: () => {},
console: {
log: () => {},
info: (...args) => infoLogs.push(args),
warn: () => {},
error: () => {}
}
});
let thrown = null;
@@ -109,6 +151,17 @@ test("forwardGetData does not retry non-retryable HTTP status", async () => {
assert.ok(thrown);
assert.strictEqual(callCount, 1);
const failedEvent = infoLogs.find(
(entry) => entry[0] === "relay_request_failed"
);
assert.ok(failedEvent);
const failedPayload = failedEvent[1];
assert.strictEqual(failedPayload.statusClass, "4xx");
assert.strictEqual(failedPayload.attemptsMade, 1);
assert.strictEqual(failedPayload.retryCountUsed, 0);
assert.ok(typeof failedPayload.elapsedMs === "number");
});
test("forwardGetData does not retry unauthorized status", async () => {
@@ -258,6 +311,210 @@ test("forwardGetData applies timeout and appendHash=false behavior", async () =>
assert.strictEqual(capturedOptions.headers.Accept, "application/json");
});
test("forwardGetData telemetry request id is stable across lifecycle events", async () => {
const infoLogs = [];
let callCount = 0;
const mod = loadRelayForwardingModule({
axios: {
get: async () => {
callCount += 1;
if (callCount === 1) {
const error = new Error("service unavailable");
error.response = { status: 503 };
throw error;
}
return { data: { ok: true }, status: 200 };
}
},
getToken: async () => ({ access_token: "token" }),
hashAPIPath: () => "&hash=abc",
azureHeaders: () => ({ headers: {} }),
redactSensitive: (value) => value,
consoleLogger: () => {},
console: {
log: () => {},
info: (...args) => infoLogs.push(args),
warn: () => {},
error: () => {}
}
});
await mod.forwardGetData({
queryUrl: "incidents?$top=1",
maxRetries: 1,
retryBaseDelayMs: 0,
retryMaxDelayMs: 0
});
const lifecycleEvents = infoLogs
.filter((entry) =>
[
"relay_request_started",
"relay_request_retrying",
"relay_request_succeeded"
].includes(entry[0])
)
.map((entry) => entry[1]);
assert.strictEqual(lifecycleEvents.length, 3);
const relayRequestIds = lifecycleEvents.map(
(event) => event.relayRequestId
);
assert.ok(
relayRequestIds.every((id) => typeof id === "string" && id.length > 0)
);
assert.strictEqual(new Set(relayRequestIds).size, 1);
});
test("forwardGetData relayPolicy override controls retry behavior", async () => {
let callCount = 0;
const infoLogs = [];
const mod = loadRelayForwardingModule({
axios: {
get: async () => {
callCount += 1;
if (callCount < 3) {
const error = new Error("temporary outage");
error.response = { status: 503 };
throw error;
}
return { data: { ok: true }, status: 200 };
}
},
getToken: async () => ({ access_token: "token" }),
hashAPIPath: () => "&hash=abc",
azureHeaders: () => ({ headers: {} }),
redactSensitive: (value) => value,
consoleLogger: () => {},
console: {
log: () => {},
info: (...args) => infoLogs.push(args),
warn: () => {},
error: () => {}
}
});
const result = await mod.forwardGetData({
queryUrl: "incidents?$top=1",
maxRetries: 0,
relayPolicy: {
maxRetries: 2,
retryBaseDelayMs: 0,
retryMaxDelayMs: 0,
method: "GET"
}
});
assert.strictEqual(callCount, 3);
assert.deepStrictEqual(result.data, { ok: true });
const startEvent = infoLogs.find(
(entry) => entry[0] === "relay_request_started"
);
assert.ok(startEvent);
assert.strictEqual(startEvent[1].maxRetries, 2);
assert.strictEqual(startEvent[1].method, "GET");
});
test("forwardGetData blocks retries for non-idempotent methods by default", async () => {
let callCount = 0;
const infoLogs = [];
const mod = loadRelayForwardingModule({
axios: {
get: async () => {
callCount += 1;
const error = new Error("temporary outage");
error.response = { status: 503 };
throw error;
}
},
getToken: async () => ({ access_token: "token" }),
hashAPIPath: () => "&hash=abc",
azureHeaders: () => ({ headers: {} }),
redactSensitive: (value) => value,
consoleLogger: () => {},
console: {
log: () => {},
info: (...args) => infoLogs.push(args),
warn: () => {},
error: () => {}
}
});
let thrown = null;
try {
await mod.forwardGetData({
queryUrl: "incidents?$top=1",
relayPolicy: {
method: "POST",
maxRetries: 3,
retryBaseDelayMs: 0,
retryMaxDelayMs: 0
}
});
} catch (error) {
thrown = error;
}
assert.ok(thrown);
assert.strictEqual(callCount, 1);
const retryEvents = infoLogs.filter(
(entry) => entry[0] === "relay_request_retrying"
);
assert.strictEqual(retryEvents.length, 0);
const failedEvent = infoLogs.find(
(entry) => entry[0] === "relay_request_failed"
);
assert.ok(failedEvent);
assert.strictEqual(failedEvent[1].method, "POST");
});
test("forwardGetData can allow retries for non-idempotent methods when explicitly enabled", async () => {
let callCount = 0;
const mod = loadRelayForwardingModule({
axios: {
get: async () => {
callCount += 1;
if (callCount === 1) {
const error = new Error("temporary outage");
error.response = { status: 503 };
throw error;
}
return { data: { ok: true }, status: 200 };
}
},
getToken: async () => ({ access_token: "token" }),
hashAPIPath: () => "&hash=abc",
azureHeaders: () => ({ headers: {} }),
redactSensitive: (value) => value,
consoleLogger: () => {}
});
const result = await mod.forwardGetData({
queryUrl: "incidents?$top=1",
relayPolicy: {
method: "PATCH",
maxRetries: 1,
retryBaseDelayMs: 0,
retryMaxDelayMs: 0,
allowNonIdempotentRetries: true
}
});
assert.strictEqual(callCount, 2);
assert.deepStrictEqual(result.data, { ok: true });
});
const run = async () => {
let passed = 0;
for (const currentTest of tests) {