diff --git a/package.json b/package.json index 2ac3fe4..4e81f8a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hmcts/opal-frontend-common-node", "type": "module", - "version": "0.0.42", + "version": "0.0.43", "license": "MIT", "description": "Common nodejs library components for opal", "engines": { diff --git a/src/constants/default-proxy-config.ts b/src/constants/default-proxy-config.ts index d70b245..f6fe662 100644 --- a/src/constants/default-proxy-config.ts +++ b/src/constants/default-proxy-config.ts @@ -4,4 +4,5 @@ export const DEFAULT_PROXY_CONFIG: Readonly = Object.freeze( opalFinesServiceUrl: null, opalUserServiceUrl: null, opalRmServiceUrl: null, + timeoutInMilliseconds: null, }); diff --git a/src/interfaces/proxy-config.ts b/src/interfaces/proxy-config.ts index 7088952..3456499 100644 --- a/src/interfaces/proxy-config.ts +++ b/src/interfaces/proxy-config.ts @@ -2,6 +2,7 @@ class ProxyConfiguration { opalFinesServiceUrl: string | null = null; opalUserServiceUrl: string | null = null; opalRmServiceUrl: string | null = null; + timeoutInMilliseconds: number | null = null; constructor(initialValues?: Partial) { Object.assign(this, initialValues); diff --git a/src/proxy/opal-api-proxy/README.md b/src/proxy/opal-api-proxy/README.md new file mode 100644 index 0000000..47ce578 --- /dev/null +++ b/src/proxy/opal-api-proxy/README.md @@ -0,0 +1,85 @@ +# Opal API Proxy Timeout and Retry Contract + +`OpalApiProxy` forwards requests from an opal-frontend node server to an upstream API service. +It applies a timeout to the upstream connection, but it never retries a request. +Only the consuming opal-frontend can decide whether replaying its original request is +safe. + +## Timeout configuration + +`DEFAULT_PROXY_CONFIG.timeoutInMilliseconds` is intentionally `null`. The common node library does not own an +environment-specific proxy timeout value. + +Pass the timeout, in milliseconds, as the third argument when creating the +proxy. The consuming application owns this value so it can be configured per +environment. + +```ts +const proxyConfiguration: ProxyConfiguration = { + ...DEFAULT_PROXY_CONFIG, + opalFinesServiceUrl: config.get('opal-api.opal-fines-service'), + opalUserServiceUrl: config.get('opal-api.opal-user-service'), + timeoutInMilliseconds: config.get('opal-api.timeoutInMilliseconds'), +}; + +if (proxyConfiguration.timeoutInMilliseconds === null) { + throw new Error('Missing opal-api.timeoutInMilliseconds configuration.'); +} + +if (proxyConfiguration.opalFinesServiceUrl) { + app.use( + '/opal-fines-service', + OpalApiProxy(proxyConfiguration.opalFinesServiceUrl, ipLoggingEnabled, proxyConfiguration.timeoutInMilliseconds), + ); +} +``` + +## Error responses + +When the upstream service times out or the proxy encounters a recognised +transport failure, the proxy returns the following response: + +```json +{ + "title": "Gateway Timeout", + "status": 504, + "detail": "The upstream service did not respond in time.", + "retriable": true +} +``` + +Other unexpected proxy failures return `502 Bad Gateway` with +`"retriable": false`. + +`retriable` is information for the frontend's error and retry handling. It is +not an instruction to replay every request, and the proxy does not replay any +request automatically. + +## Frontend retry example + +The optional retry support in `opal-frontend-common-ui-lib` must be registered +by the consuming application. A request then opts in individually. This is +appropriate only after the application has decided that replaying the request +is safe, for example a read-only `GET` request. + +```ts +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { httpRetryInterceptor } from '@hmcts/opal-frontend-common/interceptors/http-retry'; + +provideHttpClient(withInterceptors([httpRetryInterceptor])); +``` + +```ts +import { withHttpRetry } from '@hmcts/opal-frontend-common/interceptors/http-retry'; + +this.http.get('/api/accounts/123', { + context: withHttpRetry({ + retryCount: 2, + retryableStatusCodes: [504], + }), +}); +``` + +Do not opt mutation requests, such as `POST`, `PUT`, `PATCH`, or `DELETE`, into +automatic retries unless the consuming application has explicitly established +that replaying them is safe. diff --git a/src/proxy/opal-api-proxy/index.ts b/src/proxy/opal-api-proxy/index.ts index fd560d3..5739653 100644 --- a/src/proxy/opal-api-proxy/index.ts +++ b/src/proxy/opal-api-proxy/index.ts @@ -1,17 +1,76 @@ import { Router } from 'express'; import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware'; import { Logger } from '@hmcts/nodejs-logging'; +import { Socket } from 'node:net'; +import type { ServerResponse } from 'node:http'; const logger = Logger.getLogger('opalApiProxy'); import { rawJson, verifyContentDigest } from '../middlewares/digest-verify.middleware.js'; import { verifyResponseDigest } from '../utils/response-digest.js'; +type ProxyErrorResponse = { + title: string; + status: number; + detail: string; + retriable: boolean; +}; + +const RETRYABLE_PROXY_ERROR_CODES = new Set(['ECONNRESET', 'ENOTFOUND', 'ECONNREFUSED', 'ETIMEDOUT']); + +/** + * Narrows the proxy error response target to an HTTP response before writing to it. + */ +function isServerResponse(res: ServerResponse | Socket): res is ServerResponse { + return !(res instanceof Socket); +} + +/** + * Identifies timeout and transport failures that callers may safely mark as retryable. + */ +function isRetryableProxyError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const code = (error as Error & { code?: unknown }).code; + return typeof code === 'string' && RETRYABLE_PROXY_ERROR_CODES.has(code); +} + +/** + * Creates the problem response body returned by the proxy error handler. + */ +function createProxyErrorResponse( + status: number, + title: string, + detail: string, + retriable: boolean, +): ProxyErrorResponse { + return { title, status, detail, retriable }; +} + +/** + * Writes a deterministic proxy error response when Express has not already sent one. + */ +function sendProxyErrorResponse(res: ServerResponse | Socket, body: ProxyErrorResponse): void { + if (!isServerResponse(res) || res.headersSent || res.writableEnded) { + return; + } + + res.statusCode = body.status; + res.setHeader('Content-Type', 'application/problem+json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(body)); +} + /** * Creates an Express router that validates request digests and proxies requests to the Opal API. - * @param opalApiTarget Upstream Opal API base URL. - * @returns Configured Express router ready to mount in the host app. + * @param opalApiTarget Backend Opal API base URL. + * @param logEnabled Whether to log the client IP address added to the backend request. + * @param timeoutInMilliseconds Maximum time to wait for the backend proxy request before timing out. + * This is intentionally required so the consuming app owns environment-specific timeout configuration. + * @returns Configured Express router that maps proxy timeout and transport failures without replaying requests. */ -const opalApiProxy = (opalApiTarget: string, logEnabled: boolean) => { +const opalApiProxy = (opalApiTarget: string, logEnabled: boolean, timeoutInMilliseconds: number) => { const router = Router(); router.use(rawJson()); @@ -24,6 +83,7 @@ const opalApiProxy = (opalApiTarget: string, logEnabled: boolean) => { const proxy = createProxyMiddleware({ target: opalApiTarget, changeOrigin: true, + proxyTimeout: timeoutInMilliseconds, selfHandleResponse: true, on: { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -52,6 +112,32 @@ const opalApiProxy = (opalApiTarget: string, logEnabled: boolean) => { proxyRes: (proxyRes, req, res) => { void handleProxyResponse(proxyRes, req, res); }, + error: (error, _req, res) => { + // Do not replay the request here: only the frontend knows whether it is safe to retry. + if (isRetryableProxyError(error)) { + logger.warn(`Proxy timeout or transport failure when calling ${opalApiTarget}`, { + target: opalApiTarget, + message: error.message, + code: (error as Error & { code?: string }).code, + }); + sendProxyErrorResponse( + res, + createProxyErrorResponse(504, 'Gateway Timeout', 'The backend service did not respond in time.', true), + ); + return; + } + + // Keep other proxy failures deterministic without telling the frontend to retry them. + logger.error(`Unexpected proxy failure when calling ${opalApiTarget}`, { + target: opalApiTarget, + message: error instanceof Error ? error.message : String(error), + code: error instanceof Error ? (error as Error & { code?: string }).code : undefined, + }); + sendProxyErrorResponse( + res, + createProxyErrorResponse(502, 'Bad Gateway', 'The backend service could not be reached.', false), + ); + }, }, });