Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions src/constants/default-proxy-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export const DEFAULT_PROXY_CONFIG: Readonly<ProxyConfiguration> = Object.freeze(
opalFinesServiceUrl: null,
opalUserServiceUrl: null,
opalRmServiceUrl: null,
timeoutInMilliseconds: null,
});
1 change: 1 addition & 0 deletions src/interfaces/proxy-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProxyConfiguration>) {
Object.assign(this, initialValues);
Expand Down
85 changes: 85 additions & 0 deletions src/proxy/opal-api-proxy/README.md
Original file line number Diff line number Diff line change
@@ -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.
92 changes: 89 additions & 3 deletions src/proxy/opal-api-proxy/index.ts
Original file line number Diff line number Diff line change
@@ -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']);

Comment thread
TimDanielsCQI marked this conversation as resolved.
/**
* 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());
Expand All @@ -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
Expand Down Expand Up @@ -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),
);
},
Comment thread
TimDanielsCQI marked this conversation as resolved.
Comment thread
TimDanielsCQI marked this conversation as resolved.
},
});

Expand Down